Connecting metrics¶
How do your metrics relate? Five tools, increasing in subtlety: correlations (instantaneous), causal lag (delayed), adstock decay (lag spread over a window), time-varying phases (correlations that shift across the window), and seasonality (calendar-driven multiplicative modulation).
Correlations¶
A correlation pair pins the relationship between two metric draws at
the same (entity, period) cell. The builder accepts three shapes for a
connection: a relationship word (mapped to a fixed coefficient), an
explicit numeric coefficient in [-1, 1], or a dict with named fields.
The relationship vocabulary:
| Word | Coefficient |
|---|---|
mirrors |
0.75 |
driven_by |
0.55 |
related |
0.40 |
hints_at |
0.20 |
independent |
0.00 |
hints_against |
-0.20 |
resists |
-0.40 |
opposes |
-0.55 |
inverts |
-0.75 |
from plotsim import create
config = create(
about="SaaS health",
unit="company",
window=("2024-01", "2024-12", "monthly"),
metrics=[
{"name": "engagement", "type": "score", "polarity": "positive"},
{"name": "mrr", "type": "amount", "polarity": "positive", "range": [50, 50000]},
{"name": "churn_risk", "type": "score", "polarity": "negative"},
{"name": "support_tickets", "type": "count", "polarity": "negative"},
],
connections=[
"mrr driven_by engagement",
"churn_risk opposes engagement",
"support_tickets -0.40 engagement",
],
segments=[{"name": "active", "count": 40, "archetype": "growth"}],
)
about: SaaS health
unit: company
window: { start: "2024-01", end: "2024-12", every: monthly }
metrics:
- { name: engagement, type: score, polarity: positive }
- { name: mrr, type: amount, polarity: positive, range: [50, 50000] }
- { name: churn_risk, type: score, polarity: negative }
- { name: support_tickets, type: count, polarity: negative }
connections:
- mrr driven_by engagement
- churn_risk opposes engagement
- "support_tickets -0.40 engagement"
segments:
- { name: active, count: 40, archetype: growth }
The engine applies the correlation matrix via a Cholesky-decomposed
Gaussian copula at each cell. If your matrix isn't positive-definite,
the engine projects it to the nearest PD matrix (Higham 2002) and
records the per-pair adjustment in the manifest's
correlation_adjustments section.
Trajectory-aware compensation¶
Two segments with the same growth archetype will produce
trajectory-correlated metrics for free — the structural covariance from
the shared trajectory pushes positive-polarity pairs together without
any explicit correlation. To prevent the declared coefficient from
adding on top of that structural baseline, the builder enables
trajectory-aware compensation by default: the engine subtracts the
trajectory's contribution from each declared pair before the copula
sees the target. The compensated targets are recorded in the manifest's
correlation_compensations section.
Compensation is skipped (with a warning) when the config declares more
than twenty metrics — the additive decomposition becomes too noisy at
that scale. Bare-engine callers can opt out via
compensate_correlations: false in the engine config; builder users
get compensation automatically.
Causal lag¶
When metric B follows metric A with a delay, declare it on B:
Expansion at period T reads marketing_spend's effective trajectory
position at period T - 2. The engine validates that no causal-lag
cycles exist and toposorts the metric list so drivers resolve before
their followers.
Lag caps per granularity: monthly 120 periods, weekly 520, daily 3,650.
Adstock-style decay¶
A simple lag is one period in the past. Sometimes you want the driver's
effect to spread over a window — classic marketing-mix-modeling adstock.
Add decay_window (and optionally decay_kernel):
conversions at period T reads a weighted sum of ad_impressions
across periods [T - delay - decay_window + 1, T - delay]. The
geometric kernel (default) gives 0.5^s weights — a one-period
half-life. The linear kernel tapers linearly from decay_window down
to 1.
Decay requires both follows and delay. NaN cells in the decay
window are dropped and the surviving weights renormalized.
Time-varying correlations¶
When the relationship between two metrics changes across the window —
say, engagement and revenue are tightly coupled in the first half but
decouple after a price change — use connection_phases. Each phase
declares a [start_period, end_period] window (inclusive) and its own
connection list.
config = create(
about="Price-change experiment",
unit="customer",
window=("2024-01", "2024-12", "monthly"),
metrics=[
{"name": "engagement", "type": "score", "polarity": "positive"},
{"name": "mrr", "type": "amount", "polarity": "positive", "range": [50, 50000]},
],
connections=[
"mrr driven_by engagement",
],
connection_phases=[
{
"start_period": 0,
"end_period": 5,
"connections": ["mrr mirrors engagement"],
},
{
"start_period": 6,
"end_period": 11,
"connections": ["mrr hints_at engagement"],
},
],
segments=[{"name": "active", "count": 50, "archetype": "growth"}],
)
about: Price-change experiment
unit: customer
window: { start: "2024-01", end: "2024-12", every: monthly }
metrics:
- { name: engagement, type: score, polarity: positive }
- { name: mrr, type: amount, polarity: positive, range: [50, 50000] }
connections:
- mrr driven_by engagement
connection_phases:
- start_period: 0
end_period: 5
connections:
- mrr mirrors engagement
- start_period: 6
end_period: 11
connections:
- mrr hints_at engagement
segments:
- { name: active, count: 50, archetype: growth }
Phases must be non-overlapping. Periods not covered by any phase fall
back to the baseline connections. The baseline must be non-empty when
connection_phases is set — phases override the baseline; they don't
replace it. Up to sixty-four phases per config.
The engine builds one Cholesky factor per phase, each independently compensated and projected.
Seasonality¶
Calendar months that lift or depress every metric center multiplicatively. Each seasonal effect declares the months it covers (1..12) and a strength.
Per-period effective strength is the sum of every effect whose month
set contains the calendar month of the period. The engine multiplies
each metric's center by (1 + effective_strength) after polarity
inversion and before correlation sampling.
Per-metric and per-segment sensitivity¶
Two knobs scale how much each effect reaches each metric and each
segment. Both default to 1.0 (follow the global strength at face
value).
metrics=[
{
"name": "retail_sales",
"type": "amount",
"polarity": "positive",
"range": [100, 5000],
"seasonal_sensitivity": 1.5, # amplify global seasonality
},
{
"name": "support_tickets",
"type": "count",
"polarity": "negative",
"seasonal_sensitivity": 0.0, # immune to seasonality
},
]
segments=[
{
"name": "online_only",
"count": 30,
"archetype": "growth",
"seasonal_sensitivity": 1.0,
},
{
"name": "in_store",
"count": 30,
"archetype": "growth",
"seasonal_sensitivity": 2.0, # bricks-and-mortar amplifies Q4
},
]
metrics:
- name: retail_sales
type: amount
polarity: positive
range: [100, 5000]
seasonal_sensitivity: 1.5
- name: support_tickets
type: count
polarity: negative
seasonal_sensitivity: 0.0
segments:
- { name: online_only, count: 30, archetype: growth, seasonal_sensitivity: 1.0 }
- { name: in_store, count: 30, archetype: growth, seasonal_sensitivity: 2.0 }
Negative sensitivity values invert and scale: -0.5 halves the global
effect's sign and magnitude.
The manifest's seasonal_decomposition section snapshots the
per-period strength array plus the per-metric and per-segment
sensitivities, so a downstream consumer can reproduce the effective
modulation cell by cell.
Caps and gotchas¶
- Up to 1,225 correlation pairs per config (50-metric upper bound on unique pairs).
- Duplicate
(metric_a, metric_b)entries are rejected at load — unordered pairs. - Explicit
coefficient: 0.0entries are skipped at matrix assembly and emit aRedundantCorrelationWarning(zero is the default for unlisted pairs). - The seasonal effects list is capped at 12 entries; the modulation
step short-circuits when the global summed strength is
0.0.
Going deeper¶
- Shaping metrics — distributions and archetypes that define the shapes correlations couple.
- Running experiments — when the relationship shift you're modeling is a treatment effect, not a calendar effect.
- Reference: Manifest schema — every section the engine records about correlations, compensations, and seasonal decomposition.