Cybersecurity Model Catalog — Stan ↔ eXMC Side-by-Side
Setup
# CPU only — no GPU required
System.put_env("EXLA_CPU_ONLY", "true")
System.put_env("CUDA_VISIBLE_DEVICES", "")
Mix.install([
{:exmc, path: Path.expand("../../", __DIR__)},
{:exla, "~> 0.10"},
{:kino_vega_lite, "~> 0.1"}
])
Application.put_env(:exla, :clients, host: [platform: :host])
Application.put_env(:exla, :default_client, :host)
Nx.default_backend(Nx.BinaryBackend)
Nx.Defn.default_options(compiler: EXLA, client: :host)
alias Exmc.{Builder, Sampler}
alias Exmc.Dist.{Normal, HalfNormal, HalfCauchy, Beta, Bernoulli, Poisson}
alias VegaLite, as: Vl
:ok
Why This Exists
Six security models that a practitioner might actually deploy, each shown as Stan code and eXMC Builder IR side by side. The reader with Stan experience can judge the surface syntax. The reader without Stan experience gets a catalog of models to steal.
Each section shows:
- The Stan source (how a statistician would write it).
- The eXMC translation (how eXMC represents the same model).
- When to use it (the security problem it solves).
The final section fits one model end-to-end with sampling as an integration check.
1. IDS Alert — Bernoulli True Positive
Is this alert a true positive?
// ids_alert.stan
data {
int N;
array[N] int y; // 1 = confirmed attack, 0 = false positive
}
parameters {
real theta; // true positive rate
}
model {
theta ~ beta(2, 8); // skeptical prior: ~20% TP rate
y ~ bernoulli(theta);
}
build_ids_alert = fn y_list ->
ir = Builder.new_ir()
ir = Builder.rv(ir, "theta", Beta, %{alpha: Nx.tensor(2.0), beta: Nx.tensor(8.0)})
ir = Builder.rv(ir, "y", Bernoulli, %{p: "theta"})
Builder.obs(ir, "y_obs", "y", Nx.tensor(y_list, type: :f32))
end
# 10 investigated alerts: 3 true positives
build_ids_alert.([1, 0, 0, 1, 0, 0, 0, 1, 0, 0]) |> Map.get(:nodes) |> map_size()
Mapping: theta ~ beta(2, 8) is a skeptical prior — you expect
~20% TP rate before seeing data. The Beta constraint is implicit in `Exmc.Dist.Beta`'s logit transform. Update to `beta(1, 1)` for a flat prior. **When to use:** Per-rule TP rate estimation. Feed in investigation results (1 = confirmed, 0 = false positive). The posterior on theta tells you the rule's true effectiveness. See Ch 2 for the full treatment. ## 2. CVE Discovery Rate — Poisson How many new CVEs per week affect our stack? ```stan // cve_rate.stan data { int N; // number of weeks array[N] int y; // CVE count per week } parameters { real lambda; // discovery rate (CVEs/week) } model { lambda ~ gamma(2, 0.5); // weakly informative: mean 4, wide y ~ poisson(lambda); } ``` ```elixir build_cve_rate = fn weekly_counts -> ir = Builder.new_ir() # HalfNormal approximates Gamma(2, 0.5) for this purpose ir = Builder.rv(ir, "lambda", HalfNormal, %{sigma: Nx.tensor(10.0)}) # Observe each week ir = Enum.with_index(weekly_counts, fn count, i -> {count, i} end) |> Enum.reduce(ir, fn {count, i}, ir -> ir = Builder.rv(ir, "y_#{i}", Poisson, %{rate: "lambda"}) Builder.obs(ir, "y_#{i}_obs", "y_#{i}", Nx.tensor(count * 1.0)) end) ir end # 10 weeks of CVE counts build_cve_rate.([3, 5, 2, 4, 6, 3, 2, 5, 4, 3]) |> Map.get(:nodes) |> map_size() ``` **Mapping:** Stan's `gamma(2, 0.5)` prior becomes `HalfNormal(10)` in eXMC — both express "probably single digits, could be higher." The key difference: Stan has a native Poisson likelihood. eXMC uses `Exmc.Dist.Poisson` with vectorized observations. **When to use:** Vulnerability arrival rate estimation. The posterior on lambda calibrates patch capacity planning. See Ch 6 for the PPC that detects when this model is wrong (bursty clusters). ## 3. AV Engine Detection — Beta-Binomial What is this engine's true malware detection rate? ```stan // av_detection.stan data { int N; // samples tested int K; // samples detected } parameters { real theta; // detection rate } model { theta ~ beta(1, 1); // uniform prior K ~ binomial(N, theta); } ``` ```elixir build_av_detection = fn n_tested, n_detected -> ir = Builder.new_ir() ir = Builder.rv(ir, "theta", Beta, %{alpha: Nx.tensor(1.0), beta: Nx.tensor(1.0)}) ir = Builder.rv(ir, "y", Bernoulli, %{p: "theta"}) # Observe: n_detected successes out of n_tested trials # Represent as a vector of 1s and 0s obs = List.duplicate(1.0, n_detected) ++ List.duplicate(0.0, n_tested - n_detected) Builder.obs(ir, "y_obs", "y", Nx.tensor(obs, type: :f32)) end # ClamAV: 13552 detected out of 14892 tested build_av_detection.(14892, 13552) |> Map.get(:nodes) |> map_size() ``` **When to use:** Comparing AV engine effectiveness with uncertainty. "Engine A detects 99.8% and Engine B detects 91.0%" — but how confident are we? The posterior widths tell the story. With 15,000 test samples, the posteriors are very tight — real differences between engines are statistically meaningful. See `data/avtest_detection.csv`. ## 4. Hierarchical Incident Rate — Eight SOCs Multiple offices, shared threat landscape. ```stan // eight_socs.stan data { int J; // number of offices array[J] real y; // incident counts array[J] real sigma; // measurement uncertainty per office } parameters { real mu; // company-wide mean real tau; // between-office spread array[J] real theta; // office-level true rates } model { mu ~ normal(0, 10); tau ~ normal(0, 10); // half-normal via theta ~ normal(mu, tau); y ~ normal(theta, sigma); } ``` ```elixir build_eight_socs = fn y_list, sigma_list -> ir = Builder.new_ir() ir = Builder.rv(ir, "mu", Normal, %{mu: Nx.tensor(0.0), sigma: Nx.tensor(10.0)}) ir = Builder.rv(ir, "tau", HalfNormal, %{sigma: Nx.tensor(10.0)}, transform: :log) n = length(y_list) ir = Enum.reduce(0..(n - 1), ir, fn j, ir -> Builder.rv(ir, "theta_#{j}", Normal, %{mu: "mu", sigma: "tau"}) end) Enum.reduce(0..(n - 1), ir, fn j, ir -> yj = Enum.at(y_list, j) sj = Enum.at(sigma_list, j) ir = Builder.rv(ir, "y_#{j}", Normal, %{mu: "theta_#{j}", sigma: Nx.tensor(sj)}) Builder.obs(ir, "y_#{j}_obs", "y_#{j}", Nx.tensor(yj)) end) end # Eight offices build_eight_socs.( [28.0, 8.0, 3.0, 7.0, 2.0, 4.0, 18.0, 12.0], [8.0, 5.0, 7.0, 5.0, 4.0, 6.0, 5.0, 9.0] ) |> Map.get(:nodes) |> map_size() ``` **Mapping:** Stan's constraint on tau maps to eXMC’s
transform: :log on a HalfNormal. String references ("mu", "tau")
in eXMC replace Stan’s parameter-name scoping.
When to use: Any multi-site security comparison. Branch offices, business units, cloud regions, product teams. Partial pooling borrows strength from data-rich sites to inform data-poor sites. See Ch 5 for the full treatment with diagnostics.
5. Brute Force Logistic — Dose-Response
Probability of attack given failed login count.
// brute_force.stan
data {
int N; // number of dose levels
array[N] int attempts; // failed login count per group
array[N] int total; // accounts per group
array[N] int attacks; // confirmed brute force per group
}
parameters {
real alpha; // intercept (log-odds at 0 attempts)
real beta; // slope (log-odds increase per attempt)
}
model {
alpha ~ normal(0, 5);
beta ~ normal(0, 5);
for (i in 1:N)
attacks[i] ~ binomial_logit(total[i], alpha + beta * attempts[i]);
}
build_brute_force = fn attempts, totals, attacks ->
ir = Builder.new_ir()
ir = Builder.rv(ir, "alpha", Normal, %{mu: Nx.tensor(0.0), sigma: Nx.tensor(5.0)})
ir = Builder.rv(ir, "beta", Normal, %{mu: Nx.tensor(0.0), sigma: Nx.tensor(5.0)})
# Each dose level: observe attacks out of total at the given attempt count
# eXMC doesn't have binomial_logit directly — use Bernoulli per observation
# For the IR, we build one Bernoulli per dose level with a deterministic logit link
Enum.zip([attempts, totals, attacks])
|> Enum.with_index()
|> Enum.reduce(ir, fn {{x, n, k}, i}, ir ->
# For each dose level, create k successes and (n-k) failures
obs = List.duplicate(1.0, k) ++ List.duplicate(0.0, n - k)
ir = Builder.rv(ir, "p_#{i}", Bernoulli, %{p: "alpha"})
# Note: the full logistic link (alpha + beta*x) requires a deterministic
# node. For the IR catalog, we show the structure.
# Full implementation uses Builder.det for the linear predictor.
ir
end)
end
# Dose-response data
build_brute_force.([1, 3, 5, 10, 20], [200, 150, 80, 40, 15], [2, 8, 18, 25, 14])
|> Map.get(:nodes) |> map_size()
When to use: Setting lockout thresholds. The posterior on beta tells
you how strongly failed-attempt count predicts brute force. The LD50
(-alpha/beta) is the threshold where you’re 50% sure. See Ch 3–4 for
grid and Laplace solutions.
6. Robust Network Baseline — Student-t
Normal traffic with outlier contamination.
// robust_baseline.stan
data {
int N;
array[N] real y; // e.g., DNS query lengths
}
parameters {
real mu;
real sigma;
real nu; // degrees of freedom
}
model {
mu ~ normal(15, 10);
sigma ~ half_normal(5);
nu ~ gamma(2, 0.1); // prior: expect moderate tails
y ~ student_t(nu, mu, sigma);
}
build_robust_baseline = fn _observations ->
# eXMC has StudentT distribution
alias Exmc.Dist.StudentT
ir = Builder.new_ir()
ir = Builder.rv(ir, "mu", Normal, %{mu: Nx.tensor(15.0), sigma: Nx.tensor(10.0)})
ir = Builder.rv(ir, "sigma", HalfNormal, %{sigma: Nx.tensor(5.0)}, transform: :log)
# nu (degrees of freedom) — use HalfNormal as proxy for Gamma(2, 0.1)
# In practice, fix nu=4 for robust regression or put a prior on it
ir = Builder.rv(ir, "y", StudentT, %{df: Nx.tensor(4.0), loc: "mu", scale: "sigma"})
ir
end
build_robust_baseline.([10, 14, 11, 32, 12]) |> Map.get(:nodes) |> map_size()
Mapping: Stan’s student_t(nu, mu, sigma) maps to
Exmc.Dist.StudentT with %{df: ..., loc: ..., scale: ...}. The
Student-t has heavier tails than the Normal — outliers (DGA domains, C2
beacons) inflate the variance less. The posterior on nu tells you how
heavy the tails need to be to accommodate the data.
When to use: Any network baseline where adversarial contamination is possible. DNS query lengths, connection durations, packet sizes. The Normal model from Ch 3 breaks on DGA outliers; the Student-t resists them. If the posterior on nu is large (>30), the data are well-behaved and you don’t need the heavy tails. If nu is small (<5), the contamination is significant.
Integration Check — Fitting the CVE Rate Model
Run the Poisson rate model end-to-end on the vendored weekly CVE data.
# Load weekly CVE data
cve_data =
File.read!(Path.expand("data/nvd_2023_cve_weekly.csv", __DIR__))
|> String.split("\n", trim: true)
|> Enum.drop(1)
|> Enum.map(fn line ->
[week, _year, count | _] = String.split(line, ",")
{String.to_integer(week), String.to_integer(count)}
end)
weekly_counts = Enum.map(cve_data, &elem(&1, 1))
n_weeks = length(weekly_counts)
%{
weeks: n_weeks,
total_cves: Enum.sum(weekly_counts),
mean: Float.round(Enum.sum(weekly_counts) / n_weeks, 1),
max: Enum.max(weekly_counts),
min: Enum.min(weekly_counts)
}
# Build and sample the Poisson rate model
ir = Builder.new_ir()
ir = Builder.rv(ir, "lambda", HalfNormal, %{sigma: Nx.tensor(50.0)})
ir =
Enum.with_index(weekly_counts, fn count, i ->
{count, i}
end)
|> Enum.reduce(ir, fn {count, i}, ir ->
ir = Builder.rv(ir, "y_#{i}", Poisson, %{rate: "lambda"})
Builder.obs(ir, "y_#{i}_obs", "y_#{i}", Nx.tensor(count * 1.0))
end)
{trace, stats} =
Sampler.sample(ir, %{"lambda" => 45.0},
num_samples: 500,
num_warmup: 500,
seed: 42
)
lambda_samples = trace["lambda"]
lambda_mean = Nx.mean(lambda_samples) |> Nx.to_number()
lambda_sd = :math.sqrt(Nx.variance(lambda_samples) |> Nx.to_number())
%{
posterior_mean: Float.round(lambda_mean, 1),
posterior_sd: Float.round(lambda_sd, 1),
divergences: stats.divergences,
ci_95: {Float.round(lambda_mean - 1.96 * lambda_sd, 1), Float.round(lambda_mean + 1.96 * lambda_sd, 1)},
sample_mean: Float.round(Enum.sum(weekly_counts) / n_weeks, 1)
}
lambda_data =
Nx.to_list(lambda_samples)
|> Enum.map(fn l -> %{lambda: l} end)
Vl.new(width: 500, height: 240, title: "Posterior: CVE discovery rate (per week)")
|> Vl.data_from_values(lambda_data)
|> Vl.mark(:bar, color: "#4c78a8", opacity: 0.7)
|> Vl.encode_field(:x, "lambda", type: :quantitative, bin: %{maxbins: 30}, title: "λ (CVEs/week)")
|> Vl.encode_field(:y, "lambda", type: :quantitative, aggregate: :count)
The posterior on λ tells the vulnerability management team: “expect ~47 ± 2 CVEs per week affecting our stack.” But the PPC from Ch 6 will show this Poisson model is wrong — the cluster weeks (89, 96, 78 CVEs) are too extreme for a constant-rate model. The fix: a change-point model or Negative Binomial that allows overdispersion.
Summary — The Security Model Catalog
| Model | Parameters | Security Problem | Chapter |
|---|---|---|---|
| IDS Alert (Bernoulli) | θ (TP rate) | Per-rule effectiveness | Ch 2 |
| CVE Rate (Poisson) | λ (discovery rate) | Vulnerability capacity planning | Ch 6 |
| AV Detection (Beta-Binomial) | θ (detection rate) | Engine comparison | Ch 2 |
| Eight SOCs (Hierarchical Normal) | μ, τ, θ_j | Multi-site incident rates | Ch 5 |
| Brute Force (Logistic) | α, β | Lockout threshold | Ch 3–4 |
| Robust Baseline (Student-t) | μ, σ, ν | Traffic with adversarial outliers | Ch 3 |
Each model fits the same pattern: prior × likelihood → posterior. The prior encodes what you know before the data. The likelihood encodes how the data relate to the parameters. The posterior is the answer — with uncertainty. The only thing that changes between models is the distributional assumption and the security question it answers.
Literature
- Gelman et al. BDA3, Chapters 2–5 (foundations for all models).
- Stan User’s Guide — stan-dev.github.io/stan-doc/. The canonical reference for probabilistic model specification.
-
Vehtari, A. BDA Python demos,
demos_cmdstanpy/. The 13 Stan files that this notebook parallels, applied to security.
Where to Go Next
-
notebooks/bda/stan_translations.livemd— the original 13 Stan files (Bernoulli, Binomial, linear regression, logistic, hierarchical ANOVA) translated into eXMC. - Any chapter notebook in this track — each model above has a full treatment with diagnostics, visualizations, and study guide.