Skip to content

Repository files navigation

ops-kpi-anomaly-radar

Operational KPI monitoring that tells you which slice broke the number, attributing every deviation across 23 dimension slices in 20 ms and clearing the mix shift decoy that a raw threshold alert gets wrong.

ci coverage license scan p50

What this solves

  • "The number moved" is not an incident report. Most KPI monitoring stops at the top line. This decomposes each deviation additively across region, channel, product category, warehouse and customer segment, ranks slices by contribution and names the minimal explaining set. On the seeded EMEA carrier failure it returns one slice out of five carrying 95.8% of the movement, and top slice accuracy is 3 of 3 on the incidents that have a ground truth slice.
  • Composition changes fire alerts that waste an analyst's morning. A promotion that shifts the customer mix drops top level revenue per order by 40.2% while every segment's own average order value is unchanged. The rate versus mix decomposition measures that as 99.2% mix and 1.5% rate, and the alert is not raised. Measured: 1 correct rejection, 0 decoy false positives.
  • Alert thresholds are usually picked by feel and never checked. The threshold here is derived from a family wise error target, and the resulting false alarm rate is measured against seeded ground truth rather than asserted: 2 flagged days in 2,747 quiet KPI days, 0.073%, at precision 0.80 and recall 1.00.

Executive summary

An operations analytics team watches a handful of daily KPIs: orders, on time delivery, support contact rate, average order value. When one of them moves, the work is not noticing, it is explaining. The usual sequence is a dashboard alert, then forty minutes of pivoting a spreadsheet by region, then by channel, then by warehouse, until someone finds the slice that moved. On a team of four analysts each handling two or three of these a week, at a fully loaded cost around 60 dollars an hour, that is roughly 25 investigation hours a month, about 1,500 dollars, spent on work a computer can do exactly. Those figures are a representative scenario, not a measurement from a real customer. The larger cost is the alerts that turn out to be nothing: a marketing promotion changes the customer mix, average order value falls 40%, three people spend a morning on it, and nothing was ever broken.

This repository detects the movement and explains it in the same pass. Each KPI is decomposed into a trend (a trailing median with a drift correction), a weekly shape (centred medians of same weekday history) and a holiday effect (learned per holiday key as a multiplicative factor from previous occurrences of that key). Every component uses past data only, because the production job runs each morning and cannot see the future. Deviation is scored as a robust z against a MAD based scale. When a KPI deviates, the movement is decomposed algebraically across every dimension into a rate effect, a mix effect and an interaction, an identity that is exact rather than approximate, so the waterfall in the report sums to the number in the alert. The mix term is Simpson's paradox turned into a figure: when it carries the movement and the rate term does not, nothing is broken. The stack is Python (pandas, numpy, scipy), DuckDB as the local engine, sqlglot to render the aggregate SQL for Snowflake and Azure SQL as dialect targets, jinja2 for the report, and an Azure Data Factory pipeline definition with a local runner that executes the same activity graph.

The dataset is seeded, which is the point: five incidents are injected with known shape, slice and dates, so the detector can be scored instead of described. Against that ground truth, on 912 days across 1,313,280 fact rows and four monitored KPIs, the detector returns precision 0.800, recall 1.000, F1 0.889, median detection latency 0 days (mean 1.25, worst case 5 days on the gradual drift), and top slice attribution correct on 3 of the 3 incidents that have a ground truth slice. Outside every incident window it flagged 2 of 2,747 eligible KPI days, a 0.073% false alarm rate. The decoy was seen, attributed and correctly not raised. Full scan latency at 1.31M fact rows is 315 ms p50 and 417 ms p99 on a 2 vCPU Intel Xeon at 2.10 GHz with 7.8 GiB RAM. Raw scores in docs/evaluation.json, raw timings in benchmark/results/results.json.

Architecture

flowchart TD
    subgraph SRC["Source, simulated locally"]
        SEED["kpi-radar seed<br/>2.5 years, 5 dimensions<br/>4 real incidents + 1 decoy"]
        GT[("incident_ground_truth<br/>shape, slice, dates")]
    end

    subgraph WH["DuckDB warehouse, the only runtime engine"]
        FACT[("fact_ops_daily<br/>1,313,280 rows")]
        AGG[("agg_slice_daily<br/>20,976 rows<br/>one UNION ALL statement")]
        BASE[("baseline_daily<br/>104,880 rows")]
        KPID[("kpi_daily<br/>actual, expected, band, z")]
        DET[("detected_incident<br/>attribution_slice")]
    end

    subgraph MODEL["Baseline and detection"]
        DEC["decompose_frame<br/>trend + weekday + holiday<br/>past data only"]
        SIG["rolling MAD sigma<br/>full window required"]
        TIER["two tier rule<br/>robust z past 4.5 for one day, or<br/>robust z past 3.0 for two days"]
    end

    subgraph ATTR["Attribution"]
        RECON["reconcile slice baselines<br/>onto the total baseline"]
        SPLIT["rate / mix / interaction<br/>exact identity"]
        CLASS{"mix at least 70% and<br/>rate at most 25%<br/>on any dimension?"}
    end

    SEED --> FACT
    SEED --> GT
    FACT --> AGG --> DEC --> BASE
    DEC --> SIG --> TIER --> KPID
    KPID --> RECON --> SPLIT --> CLASS
    AGG --> RECON
    CLASS -- yes --> SUPP["SUPPRESSED_MIX_SHIFT<br/>logged, not paged"]
    CLASS -- no --> OPEN["OPEN incident<br/>ranked slices + narrative"]
    SUPP --> DET
    OPEN --> DET
    DET --> RPT["HTML report + CLI explain"]
    DET --> EVAL["evaluate vs ground truth<br/>precision, recall, latency"]
    GT --> EVAL

    F1["Failure boundary: empty landing table.<br/>CheckLandingData fails the run,<br/>nothing downstream executes"] -.-> FACT
    F2["Failure boundary: insufficient history.<br/>168 day warmup, and a first ever holiday<br/>occurrence issues no verdict"] -.-> DEC
    F3["Failure boundary: degenerate scale.<br/>A partial MAD window is not scored,<br/>sigma floored at 0.05% of expected"] -.-> SIG
    F4["Failure boundary: composition artefact.<br/>Mix dominant movements are recorded,<br/>never paged"] -.-> CLASS

    classDef boundary fill:#fff7ed,stroke:#c2410c,stroke-width:1px,color:#7c2d12;
    class F1,F2,F3,F4 boundary;
Loading

Tech stack

Technology Role here Why chosen for this problem
DuckDB 1.5 The only runtime engine: fact table, precomputed aggregates, baselines and results The aggregate layer is 20,976 rows and the fact is 1.3M. An embedded columnar engine rebuilds the whole aggregate in 381 ms with no server, so the demo runs from a clone with zero credentials, which is the constraint this repository is built under
pandas + numpy Rolling medians, weekday grouping, the decomposition itself The baseline is 115 daily series at depth 1 and 1,070 at depth 2. Vectorising the whole matrix at once with rolling(...).median() keeps a full rebuild at 2.3 s, where a per series Python loop would take minutes
scipy The Gaussian tail used to derive the alert thresholds The derivation needs norm.sf and nothing else. Deriving the threshold rather than picking it is the point of ADR 0001, so the dependency earns its place in one function
sqlglot Renders the aggregate statement for Snowflake and Azure SQL The aggregate SQL is the piece that would actually move to a cloud warehouse. Transpiling it and asserting the result reparses in the target dialect is an honest way to show portability without pretending to have a Snowflake account
jinja2 The self contained HTML incident report An incident report gets attached to a Jira ticket. A single file with inline SVG and no external assets survives that; anything needing a web server does not
matplotlib The KPI series chart and the benchmark chart The KPI chart overlays actual, baseline, band and two classes of flagged day across four panels. Static, scriptable and committed beats an interactive chart nobody can reproduce
rich CLI tables for scan, explain and evaluate explain is the product surface. Aligned contribution columns with the minimal set highlighted are what make terminal output pasteable into a ticket
Azure Data Factory (definition) + local runner Daily schedule, activity dependencies, retry policies ADF is the orchestrator the team already runs. The pipeline JSON is the deployment artefact; the local runner parses that same file and executes a DuckDB implementation of each activity, so the graph in the repository is the graph that would ship
Python 3.11 + argparse CLI entry point Eight subcommands with no plugin system needed. A dependency free parser keeps the container to a single pip install

Quickstart

Prerequisites:

  • Python 3.11 or newer
  • git
  • No cloud account, no database server, no credentials of any kind
git clone https://github.com/Sandeep0430/ops-kpi-anomaly-radar.git
cd ops-kpi-anomaly-radar

python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"

# 1. Generate 2.5 years of operational data with five known incidents
kpi-radar seed

# 2. Decompose every slice series into trend, weekly shape and residual
kpi-radar baseline

# 3. Detect, attribute and classify
kpi-radar scan

# 4. Why did on time delivery break the day the EMEA carrier changed?
kpi-radar explain on_time_delivery_rate 2025-02-10

# 5. Why is the 40% drop in average order value NOT an incident?
kpi-radar explain revenue_per_order 2024-06-12

# 6. Score the detector against the seeded ground truth
kpi-radar evaluate

# 7. Render the self contained HTML report to reports/incident_report.html
kpi-radar report

Everything above is also make demo. Other entry points:

make test        # pytest with coverage
make bench       # the latency benchmark, rewrites benchmark/results/
make lint        # ruff
kpi-radar run-dag                       # run the ADF activity graph locally
kpi-radar sql --dialect snowflake       # the aggregate statement, Snowflake dialect
docker compose up --build               # seed, run the DAG, then score, in containers

What runs locally and what is an Azure Data Factory definition

This distinction matters, so it is stated plainly rather than implied:

Piece Status
orchestration/adf/pl_daily_kpi_radar.json A real ADF pipeline definition with seven activities, dependsOn conditions, retry policies and timeouts. It is a deployment artefact. It is not executed by this repository and no Azure resource is created or contacted.
orchestration/adf/tr_daily_0530_utc.json A real ADF ScheduleTrigger definition, daily at 05:30 UTC. Also not executed here.
kpi-radar run-dag Actually runs. It parses the same pipeline JSON, builds the activity graph from its dependsOn edges, topologically orders it and executes a local DuckDB implementation of each activity.
The activity to function mapping Lives inside the pipeline JSON, in each activity's userProperties.localRunner. An activity with no mapping fails the local run rather than being silently skipped, which is asserted in tests/test_orchestration.py.
NotifyOnRaisedIncidents A WebActivity in ADF. Locally it only logs. There is no outbound call and no webhook secret anywhere in this repository. Both the JSON and the test say so.
Snowflake and Azure SQL Dialect targets only. kpi-radar sql --dialect snowflake renders the aggregate statement, and the tests assert it reparses as valid Snowflake and T-SQL. No connection is ever opened.

Screenshots

HTML incident report showing the mix shift decoy with its attribution waterfall

The generated HTML report (jinja2, rendered headless with playwright chromium at 1440x900). The card shown is the decoy: revenue per order fell 40.2% at robust z -80.1, the waterfall bridges the 151.72 baseline to the 90.77 actual through the customer segment contributions, and the plain English explanation states the movement is 99.2% composition and 1.5% rate, so it is not raised. Every segment's own average order value is unchanged in the table underneath.

Four KPI series with baseline bands and the seeded incidents marked

Each monitored KPI against its causal seasonal baseline and the z = 3 band. Ground truth windows are shaded: red for incidents that must alert, green for the decoy that must not. Red dots are days inside a raised incident, green squares are days the detector flagged and then explained away as a mix shift. The bottom panel is the decoy: the detector sees a 40% move and stays quiet.

Terminal capture of kpi-radar explain and the test suite

Real terminal output: kpi-radar explain on the EMEA step change (verdict OPEN, EMEA carrying 95.8% of the move, 99.8% rate) and on the promotion decoy (verdict SUPPRESSED_MIX_SHIFT, 101.1% mix, -1.9% rate), followed by pytest --cov=src/kpi_radar: 128 passed, 99% total coverage. The raw text is committed at docs/terminal_capture.txt.

Performance under load

Method: benchmark/run_benchmark.py builds a warehouse at each scale, then times repeated operations against the warm DuckDB file in a single process with no concurrency. Each scan figure is 12 repeated full scans; each attribution figure is 60 single day attribution calls on distinct dates. Container: Intel Xeon at 2.10 GHz, 2 logical CPUs, 7.8 GiB RAM, Python 3.11.15, DuckDB 1.5.5, Linux.

Scan latency by dataset size (attribution depth 1, 23 slices):

Scale Fact rows Aggregate rebuild Baseline rebuild Scan p50 Scan p95 Scan p99
small 59,076 56 ms 1,479 ms 133.1 ms 153.3 ms 158.6 ms
medium 656,640 259 ms 1,892 ms 273.1 ms 370.6 ms 415.7 ms
large 1,313,280 381 ms 1,988 ms 315.2 ms 400.2 ms 417.4 ms

Attribution latency by slice count (fixed 1,313,280 row fact table):

Configuration Slices Baseline rows Baseline rebuild Attribution p50 Attribution p95 Attribution p99
depth 1, single dimensions 23 104,880 2,323 ms 19.96 ms 38.05 ms 64.18 ms
depth 2, every dimension pair 214 975,840 19,306 ms 33.25 ms 53.32 ms 63.59 ms

Benchmark results: scan latency by dataset size, attribution latency by slice count, and baseline rebuild cost

Where it degrades, honestly: the per query costs scale gently, but the nightly baseline rebuild does not. Turning on attribution depth 2 multiplies the slice count by 9.3 and the rebuild by 8.3, from 2.3 s to 19.3 s, because each of the 1,070 series gets its own rolling median pass; a depth 3 configuration would multiply the slice count again into the thousands and push the rebuild past the point where it fits a morning batch window, which is why KPI_RADAR_ATTRIBUTION_DEPTH is validated to 1 or 2 rather than left open.

Architecture Decision Records

How the thresholds are derived

Not picked by feel. Two tiers, both derived from a false alarm budget across the KPI panel under Gaussian residuals:

  • Persistence tier, |z| at or above 3.0 on two consecutive days on the same side of the band. A single two sided test at 3.0 fires with probability 0.0027 per day. Across a panel of K = 4 KPIs that is one expected false alarm per 1 / (4 * 0.0027) = 93 days, roughly one a quarter, which is the budget an on call analyst will tolerate for a single day signal. Requiring the same sided excursion twice in a row squares the one sided rate, taking the panel expectation to about one false alarm per 190 years, at a cost of at most one day of latency on anything that is not a one day event.
  • Immediate tier, |z| at or above 4.5 on a single day, two sided probability 6.8e-6, so one day events (an outage, a gateway failure) still fire with zero latency without opening the door to routine three sigma noise.
  • Materiality guard, the relative deviation must also be at least 0.5%, so a statistically clean but operationally irrelevant move never becomes a ticket.

Combined, the theoretical rate under Gaussian residuals is 1.04e-5 per KPI day. The measured rate on the seeded data is 0.073%, 2 flagged days out of 2,747 eligible quiet KPI days, about 70 times the Gaussian expectation. That gap is the honest finding: operational residuals have fatter tails than a normal distribution, so a threshold derived from Gaussian tail probability is optimistic by roughly two orders of magnitude, and the only way to know by how much is to measure it. Both numbers are printed by kpi-radar evaluate and stored in docs/evaluation.json.

Per incident results against the seeded ground truth:

Incident KPI Shape Alert expected Latency Top slice Outcome
INC-01 on_time_delivery_rate step change confined to EMEA yes 0 days correct true positive
INC-02 ticket_rate_per_100_orders gradual drift in MOBILE yes 5 days correct true positive
INC-03 ticket_rate_per_100_orders single day spike, all slices yes 0 days no ground truth slice true positive
INC-04 orders weekend pattern break in RETAIL yes 0 days correct true positive
INC-05 revenue_per_order segment mix shift decoy no n/a n/a correct rejection

The one false positive is a two day on time delivery excursion on 2024-10-20 at robust z -4.16 and a relative effect of -1.07%, recorded under unmatched_detections in docs/evaluation.json. It is real noise in the seeded data, not a bug, and it is counted against precision rather than explained away.

Testing

pytest --cov=src/kpi_radar --cov-report=term: 128 tests, 99% statement coverage. The suite covers the algebra (contributions sum to the deviation, the pure mix case shows zero rate effect), the estimator properties (the drift correction removes the lag on a ramp, weekday offsets recover a known pattern, holiday factors ignore priors inside the warmup), the failure paths (bad configuration, empty warehouse, malformed date, dependency cycle, unregistered activity), and dialect transpilation for Snowflake and T-SQL.

Intentionally out of scope

  • Attribution depth 3 and above. Every dimension triple would take the slice count past 1,500 and the baseline rebuild past three minutes. Trigger to add it: an incident review where depth 2 leaves the top pair below 60% of the movement more than twice in a quarter.
  • Sub daily granularity. The model is daily and the weekly seasonal term assumes it. Trigger: an SLA requiring detection inside the same day, which would mean an hourly profile per weekday and a much larger seasonal state.
  • Alert routing, deduplication and on call escalation. NotifyOnRaisedIncidents is a WebActivity in the ADF definition and a log line locally. Trigger: the first time an incident is found in the report rather than in a notification.
  • Automatic remediation. The tool names the slice; a human opens the ticket. Trigger: none foreseen, an attribution engine should not be allowed to act.
  • Streaming or event level ingestion. The fact is daily and precomputed, per ADR 0002. Trigger: the fact table growing to the point where a full aggregate rebuild misses the 05:30 window.
  • A learned holiday model. Holiday effects come from a hardcoded calendar and at most two prior occurrences. Trigger: three or more years of history, at which point the per key factor is worth estimating with a proper interval.

Security and compliance

  • No credentials anywhere. DuckDB is a local file. Nothing here opens a network connection and there is no client library for any cloud service in the dependency list. docker compose up needs no secrets.
  • Configuration only through the environment. Every tunable is read in src/kpi_radar/config.py and validated at startup. .env.example documents each variable with a safe default; .env is gitignored.
  • What is never logged. The structured logger emits stage names, row counts, durations, KPI names, dimension names and slice labels. It never emits row level data and never emits the contents of an environment variable. The ADF WebActivity sets secureOutput: true and authenticates with a managed identity, so there is no webhook secret to leak.
  • Least privilege in the deployment shape. The ADF Script activities call stored procedures rather than issuing DDL, so the service principal needs execute on those procedures and read on the fact, not ownership of the schema.
  • Container hardening. The image runs as a non root user (uid 10001), writes only to a mounted /data volume, and has no shell entry point: the entry point is the CLI itself.
  • Reproducibility. The seeded dataset is generated from a fixed KPI_RADAR_RANDOM_SEED, so a reviewer regenerating the data gets the same incidents and can re-derive every number in this README.

Failure modes

Failure Detection Behaviour Recovery
Landing data missing or empty for the run date CheckLandingData validation activity counts fact_ops_daily rows The run fails before any downstream activity starts, so yesterday's results are never partially overwritten Fix the upstream load and rerun the pipeline; the aggregate is rebuilt in full, so there is nothing to reconcile
Not enough history for a series (new KPI, new slice, restored warehouse) expected is null and the day falls inside the 168 day warmup No verdict is issued for that day, and the day is excluded from the false positive denominator so the scorecard is not flattered Wait for history, or lower KPI_RADAR_TREND_WINDOW_DAYS and KPI_RADAR_MAD_WINDOW_DAYS knowing the bands widen
A calendar event with no prior occurrence (a first Black Friday) The holiday key has no usable prior in the history The day is marked as having no comparable history: the band is widened and no verdict is issued, rather than guessing an effect The next occurrence of that key learns from this one automatically
Degenerate scale estimate, MAD collapsing towards zero Sigma is floored at KPI_RADAR_MIN_SIGMA_REL of the expected value, and a partial MAD window is not scored at all A quiet stretch cannot turn a trivial wobble into a large z score. This was a real bug, see the war story Raise the floor if a KPI is genuinely near constant, or lengthen KPI_RADAR_MAD_WINDOW_DAYS
A movement is entirely a composition change (Simpson's paradox) Mix share at or above 70% with rate share at or below 25% on any single dimension The incident is recorded with status SUPPRESSED_MIX_SHIFT, the narrative names the dimension that explains it, and nobody is paged Tighten KPI_RADAR_MIX_SUPPRESSION_SHARE if a real regression is being masked; a suppressed incident is always stored, never dropped
Slice baselines not adding up to the total baseline Each dimension's expectations are rescaled onto the total before decomposing Contributions sum to the headline deviation exactly, so the waterfall and the alert cannot disagree. Also a real bug, fixed in 0ebd2e4 None needed; reconciliation runs on every attribution call
An ADF activity has no local implementation resolve() looks up userProperties.localRunner in the pipeline JSON The local run fails loudly with the activity name instead of skipping it, so the local DAG cannot silently diverge from the deployed one Register the function in orchestration._REGISTRY or remove the activity
A ratio KPI's denominator is zero for a slice on a given day Checked before the weighted average decomposition ValueError naming the KPI and the dimension, rather than a silent NaN propagating into the alert Investigate the slice; a day with no shipments at all is itself worth a look

Hardest problem solved

The first end to end scan raised 65 flagged KPI days on a dataset seeded with five incidents. Sorting the flags by absolute z put calendar events at the top, and the worst one was not merely wrong, it was impossible: on 25 December 2024, on time delivery rate had a baseline of 100.26 percent, so the actual value of 94.21 percent was flagged at robust z -5.9. A delivery rate cannot exceed 100 percent. The raw evidence is committed at docs/war_story_holiday_bug.txt.

The root cause was in how the holiday effect was learned. Each measure was decomposed independently, and the holiday adjustment was the median residual observed on previous occurrences of the same holiday key, applied as an additive offset. That is fine for a single count series. It is wrong for a ratio. Christmas removes roughly 70 percent of the day's volume, so both on_time_deliveries and shipments were shifted down by large absolute amounts, and those two amounts were estimated from different series with different noise. The ratio of two independently shifted numbers has no obligation to stay inside the unit interval, and on that day it did not. The deeper point is that holiday effects on operational volume are proportional, not absolute: Christmas is "a third of a normal day", not "minus 38,000 orders", and encoding a proportional effect as an additive one guarantees the error grows with the size of the effect.

The fix, commit a7fb223, learns the effect as a factor, actual / expected, and applies it multiplicatively. A numerator and a denominator that both carry the same factor leave the ratio's expectation exactly where it was. That change forced a second one: the factor cannot be learned without a baseline, and the baseline cannot be estimated cleanly across a ten day holiday cluster, so the decomposition became two passes. Pass one holds every calendar event day out of the rolling windows and measures what each holiday did. Pass two divides the holiday days by the factor learned for their key and re-estimates on the repaired series, so the windows are contiguous calendar days again. The first version of that idea kept the holidays excluded permanently, which was worse in a new way: with ten consecutive December holidays removed, the early January window reached three weeks further back and the drift extrapolation ran off the end of the peak trading season, producing a 50 percent positive residual on 3 January. Flagged days went from 65 to 20 with the multiplicative factor, then to 13 once the two pass structure removed the January artefact, and the rest came out with the threshold work.

A second, unrelated bug surfaced in the same session and is fixed in commit ab1eee8: the MAD scale estimator accepted a half full trailing window, and the first residuals available after the warmup were similar enough to each other that sigma collapsed towards its relative floor. Dividing by it turned a 2.1 percent wobble in daily orders into a robust z of -42.9, with the mirror image of +38.3 the following day. The fix is to require the full mad_window_days of residuals and extend the warmup to match, so no day is ever scored against a scale estimate built from a partial window.

Future work

  • Ship a CUSUM companion for slow drift. The measured latency on the seeded drift is 5 days, but that only works because the ramp exceeds the detection floor of a trailing baseline, roughly 3 * sigma / lag per day. A cumulative sum on the residual would catch ramps below that floor. First metric to watch after deploying: the distribution of detection latency by incident shape, because a drift arriving 5 days late is a very different product from a step arriving the same day.
  • Learn the mix suppression thresholds from analyst feedback. 70% mix and 25% rate are defensible starting points, not measured optima. Once real incidents are confirmed or dismissed in an incident register, those two numbers become a small supervised problem with a precision recall curve behind them.
  • Add a second grain to attribution: time of day, and the internal composition of a slice. Several real incidents are a slice behaving normally in aggregate while its own internal mix moves, which depth 2 catches only if the right pair of dimensions is materialised.
  • Push the aggregate and the decomposition into the warehouse. The aggregate SQL already transpiles to Snowflake, and the decomposition is medians and differences that would run as window functions. That removes the Python hop entirely for teams whose data never lands locally.
  • Track the false alarm rate as a first class production metric. The 0.073% measured here is against seeded ground truth. In production the equivalent is the share of raised incidents an analyst closes as "no action", and if that drifts above about 30% the thresholds need revisiting before people stop reading the alerts.

About

Detects when an operational KPI breaks its own seasonal baseline, then attributes the movement to the dimension slice that caused it, separating a genuine rate change from a mix shift. Measured on seeded ground truth: precision 0.80, recall 1.00, median latency 0 days.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages