Running experiments¶
How do you model an A/B test, train/test split, or cohort-evolution experiment? Four tools: treatment / control (per-segment A/B split), per-metric treatment (treatment affects only a named metric), arrival distributions (when each entity actually starts), and holdout split (temporal train/test split for ML).
Treatment / control¶
A segment's treatment block carves the segment into two arms — a
treatment arm that receives a known log-odds lift starting at a named
period, and a control arm that doesn't.
from plotsim import create
config = create(
about="Marketing email A/B test",
unit="customer",
seed=42,
window=("2024-01", "2024-12", "monthly"),
metrics=[
{"name": "open_rate", "type": "score", "polarity": "positive"},
{"name": "click_rate", "type": "score", "polarity": "positive"},
],
segments=[
{
"name": "test_cohort",
"count": 200,
"archetype": "flat",
"treatment": {
"fraction": 0.5, # 50/50 split
"lift_log_odds": 0.45, # known effect size
"start_period": 3, # treatment from period 3 onward
"treatment_label": "variant",
"control_label": "holdout",
},
},
],
)
about: Marketing email A/B test
unit: customer
seed: 42
window: { start: "2024-01", end: "2024-12", every: monthly }
metrics:
- { name: open_rate, type: score, polarity: positive }
- { name: click_rate, type: score, polarity: positive }
segments:
- name: test_cohort
count: 200
archetype: flat
treatment:
fraction: 0.5
lift_log_odds: 0.45
start_period: 3
treatment_label: variant
control_label: holdout
Mechanics:
fractionis the share in the treatment arm. The remainder goes to control. Both arms get thecontrol_label/treatment_labelcohort tag for the manifest.lift_log_oddsis applied viasigmoid(logit(p) + lift)to the entity's effective trajectory position for periods≥ start_period. A positive lift pushes treatment-arm entities toward higher positions; a negative lift pushes them lower.- Before
start_period, treatment and control share identical trajectory positions — the AC for "pre-treatment baseline is identical." - Sensible lifts are in
[-2.0, 2.0]log-odds units. Extreme values (> 1e6magnitude) are rejected as non-finite.
The manifest's treatment_cohorts section records every per-entity
assignment plus per-cohort summaries. Use it as the answer key for
"who actually got the treatment?"
RNG isolation¶
Treatment assignment uses a salted RNG independent of the arrival
RNG. The same (seed, treatment_config) produces identical
assignments regardless of what arrival shape the segment carries.
Per-metric treatment¶
The default treatment lift is trajectory-wide — it shifts every metric
the entity carries. When the treatment only affects one named metric
(say, the email open rate, leaving click rate untouched), use
target_metric:
When target_metric is set, every other metric on the treatment-arm
entity is byte-identical to its control-arm draw. The non-targeted
metrics see no lift.
Correlated-metric leakage caveat: when the targeted metric is correlated with another, some of the lift propagates through the Cholesky copula. The residual is bounded but non-zero — for ρ ≈ 0.4, the non-targeted mean shift is materially smaller than the targeted shift, but not zero. Treat this as expected, not a bug.
The load-time validator rejects target_metric values not in the
declared metrics list — a typo cannot silently produce a dataset where
the lift is invisible.
Arrival distributions¶
A segment's arrival block controls when each entity actually starts.
Default behavior: every entity starts at period 0. Set arrival to
draw per-entity start_period values from a chosen distribution.
Four shapes:
| Shape | Behavior |
|---|---|
uniform |
Even draws across [start, end) |
linear |
Triangular CDF — increasing back-loads (more late arrivals), decreasing front-loads |
step |
Discrete blocks: [(period, fraction), ...], fractions sum to 1.0 |
explicit |
Per-entity start periods supplied directly (length must match segment count) |
segments=[
{
"name": "organic_growth",
"count": 200,
"archetype": "growth",
"arrival": {
"kind": "linear",
"start": 0,
"end": 18, # last arrival at period 17
"direction": "increasing", # back-loaded
},
},
{
"name": "cohort_cuts",
"count": 100,
"archetype": "flat",
"arrival": {
"kind": "step",
"blocks": [
{"period": 0, "fraction": 0.5}, # 50 % at launch
{"period": 6, "fraction": 0.3}, # 30 % at month 6
{"period": 12, "fraction": 0.2}, # 20 % at month 12
],
},
},
]
Per-entity cells before the entity's start_period have NaN
trajectory positions. The metric generator emits None for those
cells, and _drop_cold_start_rows strips them from
per_entity_per_period facts after the build — your fact tables
contain only the periods each entity was actually active.
start: 0 and end: None are the defaults for uniform and linear
arrivals; end: None resolves to "leave each entity at least
MIN_ACTIVE_PERIODS active periods."
Holdout split¶
A temporal train/test split for ML workflows. The engine slices every
per_entity_per_period fact at cutoff = n_periods - holdout_periods
and emits both halves alongside the unsplit fact:
config = create(
about="Credit risk training data",
unit="customer",
window=("2024-01", "2024-12", "monthly"),
metrics=[
{"name": "credit_score", "type": "amount", "polarity": "positive", "range": [300, 850]},
{"name": "default_risk", "type": "score", "polarity": "negative"},
],
holdout={
"target": "default_risk",
"periods": 2, # last two months held out
},
segments=[{"name": "applicants", "count": 500, "archetype": "growth"}],
)
about: Credit risk training data
unit: customer
window: { start: "2024-01", end: "2024-12", every: monthly }
metrics:
- { name: credit_score, type: amount, polarity: positive, range: [300, 850] }
- { name: default_risk, type: score, polarity: negative }
holdout:
target: default_risk
periods: 2
segments:
- { name: applicants, count: 500, archetype: growth }
On disk you get <fact>.csv, <fact>_train.csv, and
<fact>_holdout.csv per fact. The training half has
period_index < cutoff; the holdout half has period_index >= cutoff.
Load-time gates: target must resolve to an int/float column on
some fact table; periods >= 1; n_periods - periods >= min_training_periods
(default 3). Only per_entity_per_period facts are split; dims,
bridges, and event tables are excluded by design.
holdout and quality can run on the same config. The corrupted
fact tables are sliced by date_key, so both <fact>_train.<ext> and
<fact>_holdout.<ext> carry their proportional share of injected
issues. The manifest's quality_injections.row_indices are keyed
against the unsplit corrupted table, so a row index points at the
pre-split row regardless of which file it ended up in.
Entity features¶
When you need a per-entity flat feature table — one row per entity
with per-metric aggregates — set entity_features: true:
config = create(
about="Churn prediction features",
unit="customer",
window=("2024-01", "2024-12", "monthly"),
metrics=[
{"name": "engagement", "type": "score", "polarity": "positive"},
{"name": "churn_risk", "type": "score", "polarity": "negative"},
],
entity_features=True,
segments=[{"name": "active", "count": 200, "archetype": "growth"}],
)
about: Churn prediction features
unit: customer
window: { start: "2024-01", end: "2024-12", every: monthly }
metrics:
- { name: engagement, type: score, polarity: positive }
- { name: churn_risk, type: score, polarity: negative }
entity_features: true
segments:
- { name: active, count: 200, archetype: growth }
The output adds _entity_features.<ext> — one row per entity with
per-metric _mean, _std, _slope, _first, _last, _peak_period
aggregates. Optional archetype and final_trajectory_position
ground-truth label columns are on by default.
The dict form narrows the metric set or strips labels:
Gates: manifest.include = true (labels are read from the manifest
payload), quality.quality_issues == [], every named metric must
resolve to a numeric fact column. When holdout.enabled is true,
aggregation is restricted to training periods and target-metric
columns are dropped to prevent label leakage.
entity_features is not supported with output.format: sql.
Multi-source overlap¶
When the dataset models the same entities recorded in multiple upstream
systems (e.g. CRM and billing each have their own copy of "customer"),
declare a sources block. The engine emits a drifted per-source copy
of each per_entity dim alongside the canonical dim, with optional
drift on names, attributes, and key formats.
Two to five sources required when set. Each SourceDeclaration produces
a dim_<entity>_<source> table. Ground-truth mappings between
canonical and per-source identifiers land in the manifest's
source_entity_mappings list — the answer key for entity resolution
exercises.
Key schemes: prefix_padded (PK with zero-padded suffix),
numeric (bare integer), uuid_short (truncated UUID).
Going deeper¶
- Connecting metrics — time-varying correlations let the relationship shift across phases without an explicit treatment.
- Shaping metrics — segment baselines and archetypes let you stage population-level differences without an A/B split.
- Reference: Manifest schema —
treatment_cohorts,holdout,source_entity_mappings,archetype_assignmentsare the ground-truth sections.