ef3m
Bet outcomes are rarely one bell curve. A strategy that cuts losers and lets winners run produces many small losses and a few larger gains; a series of concurrent bets clusters around a few typical levels. A mixture of two Gaussians,
describes both with five numbers. EF3M, the Exact Fit of the first 3 Moments (López de Prado and Foreman, 2014), finds them from the sample’s moments rather than from the data points, so it needs only five sums over the sample, however long it is.
AFML uses the fitted mixture twice. In §10.2 its CDF turns the number of concurrent long and short bets into a bet size; in §15.4.1 its two means stand in for the average loss and the average gain when estimating the probability that a strategy fails.
How the fit works
Section titled “How the fit works”A two-component mixture has five parameters, so five moments pin it down. Each raw moment of the mixture is a polynomial in them; for example and . The equations have no closed-form solution, so EF3M iterates. Given a guess for and , it solves in turn:
- from ;
- from , then from ;
- a new from (variant 1, four moments), or a new from and a new from (variant 2, five moments).
It repeats until stops moving by more than epsilon, keeping whichever iterate
reproduces the five moments with the smallest squared error. A step whose variance comes out
negative, or whose leaves , ends that attempt.
fit_m2n runs that iteration from a grid of starting values
for ,
each with a random starting , and returns the best fit found. The random start means two
calls give slightly different answers. The paper’s remedy is to repeat the whole search
(n_runs) and take, for each parameter separately, the peak of a kernel density over the
runs: most_likely_parameters.
Recovering a known mixture
Section titled “Recovering a known mixture”import randomfrom openquant import ef3m
# 20,000 bet outcomes: 70% are losses around -1, 30% are wins around +2.random.seed(7)outcomes = [ random.gauss(-1.0, 1.0) if random.random() < 0.7 else random.gauss(2.0, 0.5) for _ in range(20_000)]
# EF3M works from raw moments E[x], E[x^2], ..., E[x^5].moments = [sum(x**k for x in outcomes) / len(outcomes) for k in range(1, 6)]print("raw moments:", " ".join(f"{m:.3f}" for m in moments))print("variance:", round(ef3m.centered_moment(moments, 2), 3))
# 25 independent fits of the five-moment variant, summarised by the mode of each parameter.rows = ef3m.fit_m2n(moments, epsilon=1e-4, variant=2, n_runs=25)fit = ef3m.most_likely_parameters(rows, 10_000)print("fits:", len(rows))for name in ("mu_1", "sigma_1", "mu_2", "sigma_2", "p_1"): print(f" {name:8s} {fit[name]:5.1f}")
# The simple alternative in AFML 15.4.1: the average loss and the average win.losses = [x for x in outcomes if x <= 0]wins = [x for x in outcomes if x > 0]print("average loss", round(sum(losses) / len(losses), 2), " average win", round(sum(wins) / len(wins), 2))print("share of wins", round(len(wins) / len(outcomes), 2))raw moments: -0.097 2.672 0.027 13.751 -2.775variance: 2.663fits: 25 mu_1 -1.0 sigma_1 1.0 mu_2 2.0 sigma_2 0.5 p_1 0.7average loss -1.28 average win 1.6share of wins 0.41The fit recovers the mixture that generated the data. The last two lines show why that
matters. Splitting the outcomes at zero gives an average win of 1.6 and a 41% hit rate, because
the right tail of the loss component falls above zero and is counted as winning. The mixture
separates the two populations: wins average 2.0 and are 30% of bets. Fed into
strategy_risk, the two descriptions imply quite different
probabilities of failure.
To size bets from a fitted mixture, pass it in the order [mu_1, mu_2, sigma_1, sigma_2, p_1]
as the fit argument of bet_sizing’s bet_size_reserve or
single_bet_size_mixed.
Which variant
Section titled “Which variant”On the exact moments of the mixture above, 100 runs of each variant gave:
variant | epsilon | time per run | runs within 0.05 of the truth | median error |
|---|---|---|---|---|
| 1 (four moments) | 1e-3 | < 0.1 ms | 6% | 0.96 |
| 1 | 1e-4 | 0.4 ms | 17% | 0.14 |
| 1 | 1e-5 | 3.5 ms | 69% | 0.015 |
| 2 (five moments) | 1e-3 | < 0.1 ms | 41% | 0.006 |
| 2 | 1e-4 | 0.3 ms | 100% | 0.0001 |
| 2 | 1e-5 (default) | 3.2 ms | 100% | < 1e-6 |
Variant 2 is more accurate at every setting and no slower. At coarse epsilon, two-thirds of
variant 1’s runs ended on a degenerate fit with : one Gaussian for everything
and a second, arbitrarily wide one with no weight. Variant 1’s error is measured on all five
moments, including the fifth it does not fit, which accounts for part of its higher error but
not the degenerate fits. Prefer variant 2 unless the fifth moment is too noisy to trust; it
is the default of fit_m2n and M2N::with_defaults (both defaulted to variant 1 before
#186).
From Rust
Section titled “From Rust”use openquant::ef3m::{centered_moment, most_likely_parameters, raw_moment, M2N};
// The exact raw moments of 0.7 N(-1, 1) + 0.3 N(2, 0.5^2). Parameters are ordered// [mu_1, mu_2, sigma_1, sigma_2, p_1].let truth = [-1.0, 2.0, 1.0, 0.5, 0.7];let moments = M2N::with_defaults(vec![]).get_moments(&truth, true).unwrap();assert!((moments[0] + 0.1).abs() < 1e-12); // 0.7 * -1 + 0.3 * 2
// Raw and centred moments convert both ways. The first centred moment is 0.let central = (1..=5).map(|k| centered_moment(&moments, k)).collect::<Result<Vec<_>, _>>()?;assert!(central[0].abs() < 1e-12);assert!((central[1] - 2.665).abs() < 1e-12); // the variancelet round_trip = raw_moment(¢ral, moments[0]);assert!(round_trip.iter().zip(&moments).all(|(a, b)| (a - b).abs() < 1e-9));
// 25 runs of the five-moment fit (variant 2), then the mode of each parameter.let m2n = M2N::new(moments.clone(), 1e-4, 5.0, 25, 2, 100_000, 1);let rows = m2n.mp_fit()?;assert_eq!(rows.len(), 25);let fit = most_likely_parameters(&rows, None, 10_000);for (name, want) in [("mu_1", -1.0), ("mu_2", 2.0), ("sigma_1", 1.0), ("sigma_2", 0.5), ("p_1", 0.7)]{ assert!((fit[name] - want).abs() < 0.02, "{name} = {}", fit[name]);}
// Each row's error is the squared moment error of that row's own parameters.let mut check = M2N::with_defaults(moments.clone());for row in &rows { let implied = check .get_moments(&[row.mu_1, row.mu_2, row.sigma_1, row.sigma_2, row.p_1], true) .unwrap(); let error: f64 = moments.iter().zip(&implied).map(|(a, b)| (a - b).powi(2)).sum(); assert!((error - row.error).abs() < 1e-9);}In Rust, M2N::single_fit_loop is one run and mp_fit is n_runs of them; Python’s
fit_m2n calls mp_fit.
What to watch for
Section titled “What to watch for”- Results are random and there is no seed. The starting of every attempt comes from
the thread’s random number generator. Use
n_runsandmost_likely_parameters, and round to the precision you actually need; the example is stable to one decimal with 25 runs. - Variant 2 fits about the mean. Its fourth-moment step determines only and
takes the positive root, so on raw moments it could not return a negative .
single_fit_loop,mp_fitandfit_m2ntherefore run variant 2 on the moments of and add the mean back to and ; each row’serroris still measured against the raw moments you passed. Either sign of works, as for a mixture of and , and you no longer need to centre the sample yourself. CallingM2N::fitoriter_5directly on raw moments still takes the positive root (#115). - The components may come back in either order. The search starts with above the
mean, but the iteration can end with the labels swapped. A mode taken over runs that disagree
on the labelling is meaningless, so check that the runs agree before calling
most_likely_parameters. - The mode of each parameter is not a fit.
most_likely_parameterstakes the peak of each column separately, so the five numbers it returns may come from different runs and need not reproduce the moments. Its kernel bandwidth is , it evaluates the density onrespoints (at least 10) between the column’s minimum and maximum, and it rounds to five decimals. Theerrorcolumn is ignored by default. - The sample moments carry the risk. The fifth moment of 20,000 draws is still noisy, and
heavy tails make it worse. A fit that matches noisy moments exactly is exactly wrong. Check
a fit against a histogram of the data, or against
bet_sizing’s EM fit (bet_size_reserve_full), which uses every observation. - The moments are raw, not centred.
fit_m2nexpects for . Useraw_moment(central, mean)to convert centred moments, passing the first centred moment, which is 0, ascentral[0]. mp_fitis serial. Despite the name, the runs execute one after another. Thenum_workersfield is deprecated and ignored. At the defaultepsilona run takes a few milliseconds on the example, so this rarely matters.- Until this release, a row’s
errorcould belong to other parameters. A run that converged returned its last iterate alongside the error of an earlier, better one; on the example’s moments with variant 1 atepsilon=1e-3this happened in about one run in seven, once reporting an error of 1.3 for parameters whose error was 8.1. A run now returns its best iterate. Python’sfit_m2nalso used to ignoren_runsand return at most one row.
Related modules
Section titled “Related modules”bet-sizing— sizes bets from a two-Gaussian mixture of concurrent bets (AFML §10.2), with its own EM fit.strategy-risk— the probability that a strategy fails, from the average win and loss that a mixture can supply (AFML §15.4.1).backtest-statistics— the higher moments of returns in the probabilistic and deflated Sharpe ratios.
API reference
Section titled “API reference”Python
Section titled “Python”Signatures, parameters, return values and errors for every function: openquant.ef3m.
ef3m.fit_m2n— Fit a mixture of two Gaussians to five raw moments with EF3M.ef3m.most_likely_parameters— The most likely value of each mixture parameter over many EF3M fits.ef3m.centered_moment— The `order`-th centred moment `E[(x - m_1)^order]` from raw moments.ef3m.raw_moment— Raw moments `[E[x], E[x^2], ...]` from centred moments and the mean.
Rustdoc for this commit, with every public item of openquant::ef3m (after a crates.io release the same pages are on docs.rs):