Open-source agentic AI for transaction monitoring. Just ask Jooby.What's Jube? →

Monte Carlo Simulation

What Monte Carlo simulation does

Monte Carlo simulation runs a trained model against millions of synthetic, statistically-shaped records rather than real transactions. It serves three distinct purposes: simulating a model’s behaviour before it ever reaches production, identifying which scenarios are optimal against which are merely speculative extremes, and manufacturing a dataset for predictive analytics work in the first place where real data is not otherwise available. The worked procedures below demonstrate the first purpose directly (scoring a simulated population through an already-trained model to see how it behaves) and, in Procedure 3’s filter-and-summarise step, the second (describing what the model’s activated scenarios actually look like, the same judgement a “which of these is optimal, which is merely speculative” ranking would apply to a real prescriptive exercise); the third purpose — using simulation to manufacture training data where none exists — is a natural extension of the same three-step pattern rather than a different one, and is not separately demonstrated here.

The procedure has three steps: draw a triangular-distribution vector for every input variable of a model, assemble those vectors into a simulated data frame the same shape as the training data, score that data frame through the model, and then summarise only the rows the model activated.

The worked example below uses a small synthetic fraud data set (FraudRisk, six input variables, an Activation label) and a plain R neuralnet model, since H2O deep learning has no place in AdvancedAnalytics. The pattern — triangular vectors, a simulated data frame, a recall pass, a filter-and-summarise pass — is set out in the three procedures below.

Procedure 1: Create discrete vectors with a triangular distribution for each model parameter

For each input variable of the model to be simulated, a triangular distribution is fitted from the real training data’s minimum, maximum and mean, and 100,000 values are drawn from it. The rtriangle() function (package triangle) takes the simulation count, the minimum, the maximum and the mean or mode. Doing this for every input variable and binding the results into a data frame produces a simulated record set the same shape as the model’s training data, ready to be scored.

One limitation worth naming up front: rtriangle() does not know that a variable such as Authenticated or Foreign is really a 0/1 flag rather than a continuous quantity — fitting a triangular distribution to a binary column’s min, max and mean produces fractional values spread across the whole [0, 1] range, not a mix of exact zeros and ones. This limitation is called out here rather than silently corrected.

suppressPackageStartupMessages(library(triangle))

set.seed(2026)
n <- 5000
Count_Transactions_1_Day <- rpois(n, lambda = 6)
Authenticated <- rbinom(n, 1, 0.7)
Transaction_Amt <- round(rexp(n, rate = 1 / 250), 2)
Foreign <- rbinom(n, 1, 0.08)
Different_Merchant_Types_1_Week <- rpois(n, lambda = 3)
High_Risk_Country <- rbinom(n, 1, 0.05)

logit <- -3 + 0.05 * Transaction_Amt / 100 + 1.8 * Foreign + 2.1 * High_Risk_Country +
  0.35 * Different_Merchant_Types_1_Week - 1.1 * Authenticated
probability <- 1 / (1 + exp(-logit))
Activation <- rbinom(n, 1, probability)

FraudRisk <- data.frame(Count_Transactions_1_Day, Authenticated, Transaction_Amt, Foreign,
                         Different_Merchant_Types_1_Week, High_Risk_Country, Activation)

cat("FraudRisk training rows:", nrow(FraudRisk), " base rate:", mean(FraudRisk$Activation), "\n")

Count_Transactions_1_Day <- rtriangle(100000, min(FraudRisk$Count_Transactions_1_Day),
                                       max(FraudRisk$Count_Transactions_1_Day),
                                       mean(FraudRisk$Count_Transactions_1_Day))

cat("First variable simulated - Count_Transactions_1_Day summary:\n")
print(summary(Count_Transactions_1_Day))

cat("\nHistogram bin counts (text form, in place of the console histogram):\n")
print(hist(Count_Transactions_1_Day, plot = FALSE)$counts)

Authenticated <- rtriangle(100000, min(FraudRisk$Authenticated), max(FraudRisk$Authenticated),
                            mean(FraudRisk$Authenticated))
Transaction_Amt <- rtriangle(100000, min(FraudRisk$Transaction_Amt), max(FraudRisk$Transaction_Amt),
                              mean(FraudRisk$Transaction_Amt))
Foreign <- rtriangle(100000, min(FraudRisk$Foreign), max(FraudRisk$Foreign), mean(FraudRisk$Foreign))
Different_Merchant_Types_1_Week <- rtriangle(100000, min(FraudRisk$Different_Merchant_Types_1_Week),
                                              max(FraudRisk$Different_Merchant_Types_1_Week),
                                              mean(FraudRisk$Different_Merchant_Types_1_Week))
High_Risk_Country <- rtriangle(100000, min(FraudRisk$High_Risk_Country), max(FraudRisk$High_Risk_Country),
                                mean(FraudRisk$High_Risk_Country))

SimulatedDataFrame <- data.frame(Count_Transactions_1_Day, Authenticated, Transaction_Amt, Foreign,
                                  Different_Merchant_Types_1_Week, High_Risk_Country)

cat("\nSimulatedDataFrame dimensions:", nrow(SimulatedDataFrame), "rows,",
    ncol(SimulatedDataFrame), "columns\n")
cat("\nSimulatedDataFrame head:\n")
print(head(SimulatedDataFrame, 3))

Console output:

FraudRisk training rows: 5000  base rate: 0.1302 
First variable simulated - Count_Transactions_1_Day summary:
    Min.  1st Qu.   Median     Mean  3rd Qu.     Max. 
 0.05358  4.88365  7.05692  7.33074  9.67364 15.95760 

Histogram bin counts (text form, in place of the console histogram):
 [1]  1095  3069  5180  7365  9507 11440 11724 10591  9407  8041  6844  5670
[13]  4450  3165  1792   660

SimulatedDataFrame dimensions: 100000 rows, 6 columns

SimulatedDataFrame head:
  Count_Transactions_1_Day Authenticated Transaction_Amt   Foreign
1                 3.790573    0.30703535       1150.6833 0.3173495
2                 6.787687    0.86862863        967.3515 0.5267901
3                 6.852522    0.08304967        123.6996 0.2986538
  Different_Merchant_Types_1_Week High_Risk_Country
1                        3.566598         0.2525322
2                        4.206525         0.1765564
3                        4.864761         0.7089584

Tool equivalent: none for the distribution-fitting step itself — AdvancedAnalyticsTools has no tool that “reads a training column’s min/max/mean and builds a matching triangular fit” the way this procedure does by hand. It does have the underlying random-draw primitive: Jube.Service.Agent.AdvancedAnalyticsTools.DistributionsAndHistograms.UnivariateDistributionTools, tool "DistributionRandomSample", called with DistributionKind.Triangular and parameters = [minimum, maximum, mode] (see DistributionCatalogue.cs’s Triangular entry), draws the same shaped values from a fixed seed in one call per variable — the caller still supplies the minimum, maximum and mode, typically read off the model’s own stored training-time variable statistics (AdvancedAnalyticsTrainingRunVariable.Minimum/Maximum/Mean, see docs/AnalyticalMethodology/Reference/FoundationData.md) rather than recomputed from a raw data frame. The simulated columns are assembled into the packed double[] Rows/Columns shape used everywhere in the product (AdvancedAnalyticsCache, AdvancedAnalyticsPacking.Pack), not an R data frame — see DataFramesVsAccordArrays.md for that structural comparison in full. There is no product tool that performs data.frame()’s column bind or View()’s interactive grid; a caller (agent or test) builds the packed array directly.

Procedure 2: Process the random data frame against a neural network model

The simulated data frame is scored through the same model that was trained on the real data, exactly as if it were a batch of real transactions. In place of H2O’s as.h2o()/h2o.predict(), a plain R neuralnet model plays that role here: it is trained on FraudRisk (min-max scaled, as neuralnet expects), then its compute() recall is run over the simulated data frame (scaled the same way) to give one activation probability per simulated row. Thresholding that probability at 0.5 gives a 0/1 predicted activation, which is appended back onto the simulated data frame as its own column.

suppressPackageStartupMessages(library(dplyr))
suppressPackageStartupMessages(library(neuralnet))

load("/work/monte_carlo_state1.RData")

scale_cols <- setdiff(names(FraudRisk), "Activation")
mins <- sapply(FraudRisk[scale_cols], min)
maxs <- sapply(FraudRisk[scale_cols], max)

scale_with <- function(df) {
  scaled <- df
  for (col in scale_cols) {
    scaled[[col]] <- (df[[col]] - mins[col]) / (maxs[col] - mins[col])
  }
  scaled
}

FraudRiskScaled <- scale_with(FraudRisk)
FraudRiskScaled$Activation <- FraudRisk$Activation

set.seed(2026)
Model <- neuralnet(Activation ~ Count_Transactions_1_Day + Authenticated + Transaction_Amt +
                      Foreign + Different_Merchant_Types_1_Week + High_Risk_Country,
                    data = FraudRiskScaled, hidden = 4, linear.output = FALSE, threshold = 0.05,
                    stepmax = 1e6)

cat("Neural network converged in", Model$result.matrix["steps", 1], "steps\n")

SimulatedDataFrameScaled <- scale_with(SimulatedDataFrame)

SimulatedHex <- SimulatedDataFrameScaled
SimulatedScores <- compute(Model, SimulatedHex)

SimulatedActivationProbability <- as.vector(SimulatedScores$net.result)
SimulatedActivations <- as.integer(SimulatedActivationProbability >= 0.5)

cat("\nSimulated activation probability summary:\n")
print(summary(SimulatedActivationProbability))

cat("\nSimulated activation count (0/1):\n")
print(table(SimulatedActivations))

SimulatedDataFrame <- mutate(SimulatedDataFrame, SimulatedActivations)

cat("\nSimulatedDataFrame last column, first rows:\n")
print(tail(names(SimulatedDataFrame), 1))
print(head(SimulatedDataFrame[, c("Transaction_Amt", "Foreign", "High_Risk_Country", "SimulatedActivations")], 5))

Console output:

Neural network converged in 3058 steps

Simulated activation probability summary:
   Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
0.04936 0.47759 0.69856 0.65127 0.80771 0.96276 

Simulated activation count (0/1):
SimulatedActivations
    0     1 
26880 73120 

SimulatedDataFrame last column, first rows:
[1] "SimulatedActivations"
  Transaction_Amt    Foreign High_Risk_Country SimulatedActivations
1       1150.6833 0.31734953         0.2525322                    1
2        967.3515 0.52679015         0.1765564                    0
3        123.6996 0.29865384         0.7089584                    1
4        248.2528 0.08857475         0.2815015                    0
5        667.1251 0.65774615         0.2656655                    1

(The elevated activation rate above the training base rate of 13% is a direct consequence of the Procedure 1 limitation noted above: the simulated Foreign and High_Risk_Country columns spend most of their mass in fractional territory around 0.2–0.7 rather than being mostly-zero flags, which pushes more simulated rows into the model’s higher-risk region than real transactions ever occupy.)

AdvancedAnalytics equivalent: this is exactly the product’s own recall path, not something bespoke to simulation. A trained model’s stored ModelJson snapshot is turned into a live scorer by Jube.Engine.AdvancedAnalytics.Recall.AdvancedAnalyticsModelRegistry.Build(modelJson), which returns an AdvancedAnalyticsRecallModel carrying the variable order and a Predict delegate of type Func<double[], double[]> (for the neural-network classifier family, backed by Jube.Engine.AdvancedAnalytics.Classification.NeuralNetworkClassifierRecall.FromSnapshot(...).AsFunction()). Calling that one delegate once per packed row is “process random data frame against neural network model”: there is no as.h2o() staging step because there is no separate model server to stage into, and no manual min-max rescale because the recall pipeline stores and reapplies its own training-time standardisation per Shared Statistical Primitives’s preprocessing note. Recall itself is engine backed and has no HTTP operation in the services — an agent (or, for this simulation use case, a batch job that draws simulated rows and calls the same delegate) drives it directly, the same way this script drives compute() directly rather than through a web request.

Procedure 3: Filter the data frame for activations and produce summary statistics to prescribe

With every simulated row now carrying a predicted activation, the rows the model actually activated can be pulled out into their own data frame, and summary statistics run against just those rows describe what an activation scenario typically looks like — the prescriptive payoff of the whole exercise.

suppressPackageStartupMessages(library(dplyr))

load("/work/monte_carlo_state2.RData")

SimulatedAndActivated <- filter(SimulatedDataFrame, SimulatedActivations == 1)

cat("Activated rows:", nrow(SimulatedAndActivated), "of", nrow(SimulatedDataFrame), "\n\n")

cat("summary(Count_Transactions_1_Day) restricted to activated rows:\n")
print(summary(SimulatedAndActivated$Count_Transactions_1_Day))

cat("\nsummary(Transaction_Amt) restricted to activated rows:\n")
print(summary(SimulatedAndActivated$Transaction_Amt))

cat("\nFor comparison, summary(Transaction_Amt) across all simulated rows:\n")
print(summary(SimulatedDataFrame$Transaction_Amt))

Console output:

Activated rows: 73120 of 100000 

summary(Count_Transactions_1_Day) restricted to activated rows:
    Min.  1st Qu.   Median     Mean  3rd Qu.     Max. 
 0.05358  4.95409  7.22072  7.38370  9.75887 15.91924 

summary(Transaction_Amt) restricted to activated rows:
    Min.  1st Qu.   Median     Mean  3rd Qu.     Max. 
   1.333  430.135  751.157  809.006 1142.545 2015.873 

For comparison, summary(Transaction_Amt) across all simulated rows:
    Min.  1st Qu.   Median     Mean  3rd Qu.     Max. 
   1.333  383.329  683.123  757.109 1075.402 2015.873 

In this simulated run, activated rows have a noticeably higher median Transaction_Amt (751 against 683 across the whole simulated population) — exactly the kind of prescriptive read this filtered summary is meant to produce.

Tool equivalent: the filter step itself — keeping only the rows whose recalled score crosses the activation threshold — is a host-side mask over the array Predict already returned, not a tool call; there is no AdvancedAnalyticsTools “filter rows by predicate” method, honestly, because the recalled scores are already sitting in memory as a double[] the caller indexed with. What the product does give a real tool for is the second half of this procedure — summarising the retained rows. Any of Jube.Service.Agent.AdvancedAnalyticsTools.DescriptiveStatistics.TableAndTransformTools’s "DescribeColumn" or "SummariseColumns" tools, or QuantileRankAndFrequencyTools’s "FiveNumberSummary", run against the masked rows reproduce summary()’s min/quartiles/mean/max in one call, on either a single column (DescribeColumn) or every column of the filtered set at once (SummariseColumns) — giving a rounded picture of the whole activation scenario rather than repeating summary() column by column. This is exactly the recall-and-prescribe pattern AdvancedAnalytics supports end to end: score simulated rows through the AnalyticsRecall-style Func<double[], double[]> delegate from Procedure 2, then hand the activated subset to the descriptive-statistics tool group to say what the activation scenario looks like — the same two-step shape as this procedure, with a genuine product tool at each step except the mask itself.