Tutorial: score telemetry against per-row baselines¶
In this tutorial you build a working anomaly detector: one lazy Polars query that scores every sensor reading against the baseline distribution of its own sensor, ranks the outliers even when their probabilities underflow to zero, and turns an alarm rate into a physical reading limit.
Along the way you use the four methods that carry most statistical work (pdf, sf, log_sf, isf) and parameters
that are columns rather than scalars.
You need Python >=3.10 and:
Every snippet below is executed when this page is built, and each one continues from the previous. Follow them in order and you will see exactly the output shown.
Step 1: get some readings¶
Two temperature sensors report on very different scales. Copy this into a Python session:
import polars as pl
import polars_stats as ps
readings = pl.DataFrame(
{
"sensor": ["temp-a", "temp-a", "temp-a", "temp-b", "temp-b", "temp-b"],
"reading": [9.8, 10.4, 40.0, 99.4, 101.2, 250.0],
}
)
print(readings)
shape: (6, 2)
┌────────┬─────────┐
│ sensor ┆ reading │
│ --- ┆ --- │
│ str ┆ f64 │
╞════════╪═════════╡
│ temp-a ┆ 9.8 │
│ temp-a ┆ 10.4 │
│ temp-a ┆ 40.0 │
│ temp-b ┆ 99.4 │
│ temp-b ┆ 101.2 │
│ temp-b ┆ 250.0 │
└────────┴─────────┘
Two of those six readings are faults: 40.0 on temp-a and 250.0 on temp-b. The task is to find them without
hard-coding either number.
Step 2: score against a single baseline¶
Build a Normal distribution and ask, for every reading, how much probability lies above it. That upper-tail
probability is the survival function, sf:
shared = ps.Normal(mu=10.0, sigma=0.5)
print(readings.with_columns(upper_tail=shared.sf("reading")))
shape: (6, 3)
┌────────┬─────────┬────────────┐
│ sensor ┆ reading ┆ upper_tail │
│ --- ┆ --- ┆ --- │
│ str ┆ f64 ┆ f64 │
╞════════╪═════════╪════════════╡
│ temp-a ┆ 9.8 ┆ 0.655422 │
│ temp-a ┆ 10.4 ┆ 0.211855 │
│ temp-a ┆ 40.0 ┆ 0.0 │
│ temp-b ┆ 99.4 ┆ 0.0 │
│ temp-b ┆ 101.2 ┆ 0.0 │
│ temp-b ┆ 250.0 ┆ 0.0 │
└────────┴─────────┴────────────┘
sf("reading") reads the column named reading. A bare string is always a column reference.
Sensor temp-a is scored correctly, but every temp-b reading looks impossible, including the healthy ones, because
they were scored against temp-a's baseline. One distribution cannot serve both sensors.
Step 3: give every row its own baseline¶
Put each sensor's baseline in its own frame and join it on:
baselines = pl.DataFrame(
{
"sensor": ["temp-a", "temp-b"],
"mu": [10.0, 100.0],
"sigma": [0.5, 2.0],
}
)
joined = readings.join(baselines, on="sensor", maintain_order="left")
print(joined)
shape: (6, 4)
┌────────┬─────────┬───────┬───────┐
│ sensor ┆ reading ┆ mu ┆ sigma │
│ --- ┆ --- ┆ --- ┆ --- │
│ str ┆ f64 ┆ f64 ┆ f64 │
╞════════╪═════════╪═══════╪═══════╡
│ temp-a ┆ 9.8 ┆ 10.0 ┆ 0.5 │
│ temp-a ┆ 10.4 ┆ 10.0 ┆ 0.5 │
│ temp-a ┆ 40.0 ┆ 10.0 ┆ 0.5 │
│ temp-b ┆ 99.4 ┆ 100.0 ┆ 2.0 │
│ temp-b ┆ 101.2 ┆ 100.0 ┆ 2.0 │
│ temp-b ┆ 250.0 ┆ 100.0 ┆ 2.0 │
└────────┴─────────┴───────┴───────┘
Now pass those columns as the distribution's parameters:
baseline = ps.Normal(mu="mu", sigma="sigma")
scored = joined.with_columns(upper_tail=baseline.sf("reading"))
print(scored)
shape: (6, 5)
┌────────┬─────────┬───────┬───────┬────────────┐
│ sensor ┆ reading ┆ mu ┆ sigma ┆ upper_tail │
│ --- ┆ --- ┆ --- ┆ --- ┆ --- │
│ str ┆ f64 ┆ f64 ┆ f64 ┆ f64 │
╞════════╪═════════╪═══════╪═══════╪════════════╡
│ temp-a ┆ 9.8 ┆ 10.0 ┆ 0.5 ┆ 0.655422 │
│ temp-a ┆ 10.4 ┆ 10.0 ┆ 0.5 ┆ 0.211855 │
│ temp-a ┆ 40.0 ┆ 10.0 ┆ 0.5 ┆ 0.0 │
│ temp-b ┆ 99.4 ┆ 100.0 ┆ 2.0 ┆ 0.617911 │
│ temp-b ┆ 101.2 ┆ 100.0 ┆ 2.0 ┆ 0.274253 │
│ temp-b ┆ 250.0 ┆ 100.0 ┆ 2.0 ┆ 0.0 │
└────────┴─────────┴───────┴───────┴────────────┘
That single baseline object described a different Normal on every row: Normal(10.0, 0.5) on the temp-a rows and
Normal(100.0, 2.0) on the temp-b rows. All four healthy readings now score between 0.2 and 0.7, and only the two
faults sit near zero.
Step 4: flag the faults¶
An upper tail below 0.001 means "less than a 1-in-1000 reading for this sensor". Filter on it:
shape: (2, 5)
┌────────┬─────────┬───────┬───────┬────────────┐
│ sensor ┆ reading ┆ mu ┆ sigma ┆ upper_tail │
│ --- ┆ --- ┆ --- ┆ --- ┆ --- │
│ str ┆ f64 ┆ f64 ┆ f64 ┆ f64 │
╞════════╪═════════╪═══════╪═══════╪════════════╡
│ temp-a ┆ 40.0 ┆ 10.0 ┆ 0.5 ┆ 0.0 │
│ temp-b ┆ 250.0 ┆ 100.0 ┆ 2.0 ┆ 0.0 │
└────────┴─────────┴───────┴───────┴────────────┘
Two rows, exactly the two faults, and no threshold in reading units anywhere in the code.
Step 5: rank faults whose probability underflows¶
Both faults report upper_tail of exactly 0.0. Their true probabilities are around 10^-784 and 10^-1224, far
below the smallest number a Float64 can hold, so they collapse to zero and become indistinguishable. Ask for the
logarithm instead:
shape: (6, 6)
┌────────┬─────────┬───────┬───────┬────────────┬──────────────┐
│ sensor ┆ reading ┆ mu ┆ sigma ┆ upper_tail ┆ log_tail │
│ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- │
│ str ┆ f64 ┆ f64 ┆ f64 ┆ f64 ┆ f64 │
╞════════╪═════════╪═══════╪═══════╪════════════╪══════════════╡
│ temp-b ┆ 250.0 ┆ 100.0 ┆ 2.0 ┆ 0.0 ┆ -2817.736604 │
│ temp-a ┆ 40.0 ┆ 10.0 ┆ 0.5 ┆ 0.0 ┆ -1805.013561 │
│ temp-a ┆ 10.4 ┆ 10.0 ┆ 0.5 ┆ 0.211855 ┆ -1.551851 │
│ temp-b ┆ 101.2 ┆ 100.0 ┆ 2.0 ┆ 0.274253 ┆ -1.293704 │
│ temp-b ┆ 99.4 ┆ 100.0 ┆ 2.0 ┆ 0.617911 ┆ -0.48141 │
│ temp-a ┆ 9.8 ┆ 10.0 ┆ 0.5 ┆ 0.655422 ┆ -0.422476 │
└────────┴─────────┴───────┴───────┴────────────┴──────────────┘
log_sf is computed in log space throughout, so it stays finite where sf has already rounded to zero. The temp-b
fault at -2817 is now clearly more extreme than the temp-a one at -1805, and sorting on it puts the worst fault
first. Use log_sf whenever you need to rank deep tails; use sf when you need a readable probability.
Step 6: turn the alarm rate into a reading limit¶
Operators want a number in degrees, not a probability. isf inverts the survival function: give it a tail
probability, and it returns the reading value that leaves exactly that much probability above it.
shape: (2, 4)
┌────────┬───────┬───────┬────────────┐
│ sensor ┆ mu ┆ sigma ┆ limit │
│ --- ┆ --- ┆ --- ┆ --- │
│ str ┆ f64 ┆ f64 ┆ f64 │
╞════════╪═══════╪═══════╪════════════╡
│ temp-a ┆ 10.0 ┆ 0.5 ┆ 11.545116 │
│ temp-b ┆ 100.0 ┆ 2.0 ┆ 106.180465 │
└────────┴───────┴───────┴────────────┘
One 1-in-1000 limit per sensor, derived from that sensor's own baseline: 11.5 for temp-a, 106.2 for temp-b.
Change alarm_rate and both limits move with it.
Step 7: assemble the detector¶
Nothing above needed a materialised DataFrame. Every method returned a pl.Expr, so the whole detector composes
into one lazy query that Polars optimises and evaluates in a single pass:
baseline = ps.Normal(mu="mu", sigma="sigma")
alerts = (
readings.lazy()
.join(baselines.lazy(), on="sensor", maintain_order="left")
.with_columns(
upper_tail=baseline.sf("reading"),
log_tail=baseline.log_sf("reading"),
limit=baseline.isf(alarm_rate),
)
.filter(pl.col("upper_tail") < alarm_rate)
.select("sensor", "reading", "limit", "log_tail")
.sort("log_tail")
.collect()
)
print(alerts)
shape: (2, 4)
┌────────┬─────────┬────────────┬──────────────┐
│ sensor ┆ reading ┆ limit ┆ log_tail │
│ --- ┆ --- ┆ --- ┆ --- │
│ str ┆ f64 ┆ f64 ┆ f64 │
╞════════╪═════════╪════════════╪══════════════╡
│ temp-b ┆ 250.0 ┆ 106.180465 ┆ -2817.736604 │
│ temp-a ┆ 40.0 ┆ 11.545116 ┆ -1805.013561 │
└────────┴─────────┴────────────┴──────────────┘
Two faults, each with the limit it broke and a severity score that ranks them, from a query that never left the Polars engine.
What you built¶
- A distribution parameterised from columns, so one object scores every row against its own baseline.
- Tail probabilities with
sf, tail ranking withlog_sf, and a reading-unit threshold withisf. - A single lazy query, with the statistics inline rather than in a post-processing step.
Where to next¶
- Solve a specific problem: the How-to guides cover deriving parameters from data, sampling,
nulls, and porting
scipy.statscode. - Look something up: the API reference has the distribution catalogue and the full method surface.
- Understand the design: Why polars-stats explains what this approach buys and what it costs.