Skip to content

Shaping metrics

What shape does your data take? This page covers the three knobs you turn to control it: metric type (which picks a distribution), archetype (which picks a trajectory shape), and baseline (which narrows the value range per segment).

Metric types

Every metric declares a type and a polarity. The type picks a distribution; the polarity decides whether a high trajectory position means a high or a low value.

Type Default distribution When to use Range required?
score beta (alpha=2, beta=5) A 0–1 score, percentile, normalized rate optional (defaults to [0, 1])
count poisson (lambda=5) Discrete counts: tickets, logins, visits rejected — counts are unbounded
amount lognorm or beta, picked from range Currency, sizes, anything with a meaningful unit required
index normal centered on the range midpoint NPS, satisfaction index, a scored 0–100 required

The amount type picks lognorm when the range's lower bound is zero or the ratio max/min ≥ 10 (right-skewed amounts like revenue), and beta otherwise (bounded amounts like discount percentages).

from plotsim import create

config = create(
    about="Customer health metrics",
    unit="customer",
    window=("2024-01", "2024-12", "monthly"),
    metrics=[
        {"name": "engagement",      "type": "score", "polarity": "positive"},
        {"name": "logins",          "type": "count", "polarity": "positive"},
        {"name": "mrr",             "type": "amount", "polarity": "positive", "range": [50, 50000]},
        {"name": "nps",             "type": "index",  "polarity": "positive", "range": [0, 100]},
    ],
    segments=[{"name": "active", "count": 60, "archetype": "growth"}],
)
about: Customer health metrics
unit: customer
window: { start: "2024-01", end: "2024-12", every: monthly }

metrics:
  - { name: engagement, type: score,  polarity: positive }
  - { name: logins,     type: count,  polarity: positive }
  - { name: mrr,        type: amount, polarity: positive, range: [50, 50000] }
  - { name: nps,        type: index,  polarity: positive, range: [0, 100] }

segments:
  - { name: active, count: 60, archetype: growth }

Explicit distribution

When the auto-pick is wrong, declare it. The six families: lognorm, gamma, poisson, beta, normal, weibull.

metrics=[
    {
        "name": "wait_time_seconds",
        "type": "amount",
        "polarity": "negative",
        "range": [0, 3600],
        "distribution": "gamma",
        "distribution_params": {"shape": 2.0},
    },
]
metrics:
  - name: wait_time_seconds
    type: amount
    polarity: negative
    range: [0, 3600]
    distribution: gamma
    distribution_params: { shape: 2.0 }

Required params per family: lognorm needs s; gamma and weibull need shape; beta needs alpha and beta (with optional scale); normal needs sigma; poisson accepts no params (its center is lambda, derived from the trajectory position).

Polarity

positive means high position → high value. negative flips: high position → low value. The trajectory engine inverts the position internally for negative metrics before centering the distribution. This is how "engagement up means churn risk down" falls out for free.

You can have at most fifty metrics per config.

Archetypes

Each segment names an archetype that defines the trajectory shape every entity in the segment will follow. Six shape words ship out of the box:

Word Curve Story
growth sigmoid (rising) S-curve ramp, midpoint at 50 % of the window
decline exp_decay Exponential fall from high to low
flat plateau Constant low level
seasonal oscillating Sine wave, period = 2 windows, ±0.4 amplitude
accelerating compound Compounding growth from low to high
spike_then_crash sigmoid → step → plateau Rises fast, drops abruptly, stays low

Multi-phase archetype DSL

A segment archetype can be a composite spec: chain shapes with >, declare transition periods with @.

flat > decline @ 10

reads as "flat for the first ten periods, then decline through period end." The shape on the left occupies periods 0..10; the shape on the right occupies 10..end.

For three or more phases, one @ between every pair of >:

growth > spike_then_crash > flat @ 8 @ 16

is "ramp for eight periods, crash through period sixteen, flat after."

Transition periods must be strictly ascending and lie in [1, n_periods - 1].

segments=[
    {"name": "rocket",   "count": 12, "archetype": "growth"},
    {"name": "churning", "count": 18, "archetype": "flat > decline @ 10"},
    {"name": "at_risk",  "count": 14, "archetype": "growth > spike_then_crash > flat @ 6 @ 12"},
]
segments:
  - { name: rocket,   count: 12, archetype: growth }
  - { name: churning, count: 18, archetype: "flat > decline @ 10" }
  - { name: at_risk,  count: 14, archetype: "growth > spike_then_crash > flat @ 6 @ 12" }

Segment counts must be in the range 3..5,000. The total across all segments is capped at 100,000 entities.

Baselines

Two segments with the same growth archetype can still end up at different value levels if you set per-metric baselines. The vocabulary is high, mid, low, mapped to the upper, middle, or lower third of each metric's value range.

segments=[
    {
        "name": "enterprise_steady",
        "count": 14,
        "archetype": "flat",
        "baseline": {"engagement": "high", "mrr": "high"},
    },
    {
        "name": "starter_dribble",
        "count": 24,
        "archetype": "flat",
        "baseline": {"engagement": "low", "mrr": "low"},
    },
]
segments:
  - name: enterprise_steady
    count: 14
    archetype: flat
    baseline: { engagement: high, mrr: high }
  - name: starter_dribble
    count: 24
    archetype: flat
    baseline: { engagement: low, mrr: low }

Both segments are flat — the trajectory shape is the same — but the two cohorts live in different bands of the metric's range.

Baselines only constrain the value range; they don't reshape the curve. Stack them with archetypes to get "high baseline that grows" or "low baseline that crashes."

Per-segment attributes

attributes doubles as the source for pool.<attr> columns on the auto-generated dimension table — each value becomes a per-entity choice list. The builder also rolls them up into the per-cohort labels you'll see in the manifest.

segments=[
    {
        "name": "rapid_adopter",
        "count": 18,
        "archetype": "growth",
        "attributes": {
            "industry": ["saas", "fintech"],
            "region":   ["na", "emea"],
            "plan":     ["growth", "enterprise"],
        },
        "baseline": {"engagement": "high"},
    },
]
segments:
  - name: rapid_adopter
    count: 18
    archetype: growth
    attributes:
      industry: [saas, fintech]
      region:   [na, emea]
      plan:     [growth, enterprise]
    baseline: { engagement: high }

Attribute values must be strings (or lists of strings); numerics are stringified because the underlying column is dtype: string.

Common patterns

Mixed cohorts with deliberate drama. Use a small number of segments with contrasting archetypes — growth, flat, flat > decline @ N, growth > spike_then_crash > flat @ A @ B — to produce a dataset where the population's average looks calm but individual entities tell distinct stories. The SaaS template does exactly this.

Range-bounded amounts. When a value has a meaningful natural ceiling (NPS 0–100, discount 0–25 %), set range. The engine clamps every sampled value to the range before noise, and the manifest's variance_partitions reflect the bounded scale honestly.

Distribution choice over polarity. Don't model "wait time" as positive-polarity revenue with a negative coefficient — model it as a negative-polarity amount with a wait-time-shaped distribution (gamma, typically). The downstream correlations and seasonality machinery handles polarity correctly only when polarity is declared honestly.

Going deeper