Skip to content

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:

metrics=[
    {"name": "marketing_spend", "type": "amount", "polarity": "positive", "range": [1000, 50000]},
    {
        "name": "expansion",
        "type": "score",
        "polarity": "positive",
        "follows": "marketing_spend",
        "delay": 2,
    },
]
metrics:
  - { name: marketing_spend, type: amount, polarity: positive, range: [1000, 50000] }
  - name: expansion
    type: score
    polarity: positive
    follows: marketing_spend
    delay: 2

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):

metrics=[
    {"name": "ad_impressions", "type": "count", "polarity": "positive"},
    {
        "name": "conversions",
        "type": "count",
        "polarity": "positive",
        "follows": "ad_impressions",
        "delay": 1,
        "decay_window": 6,
        "decay_kernel": "geometric",
    },
]
metrics:
  - { name: ad_impressions, type: count, polarity: positive }
  - name: conversions
    type: count
    polarity: positive
    follows: ad_impressions
    delay: 1
    decay_window: 6
    decay_kernel: geometric

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.

seasonality=[
    {"months": [11, 12],    "strength":  0.30},  # Q4 budget flush
    {"months": [6, 7, 8],   "strength": -0.12},  # summer slowdown
]
seasonality:
  - { months: [11, 12],   strength:  0.30 }
  - { months: [6, 7, 8],  strength: -0.12 }

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.0 entries are skipped at matrix assembly and emit a RedundantCorrelationWarning (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.