From 789dcb03d40befffaa670256f5f645be147099ff Mon Sep 17 00:00:00 2001 From: Emi Griffith Date: Sun, 19 Jul 2026 22:45:57 -0700 Subject: [PATCH] Score every metric equally in the overall total (#218) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overall climate-shift score was a weighted roll-up (feels-like and humidity 2x, wind and gusts 0.5x, and so on). Make it an equal average instead: every metric — and, through each metric's own mean over the 10/25/50/75/90 percentiles, every percentile — counts the same. - scoring._overall: plain mean of the present metrics' divergences, mapped to 0-100, replacing the weighted sum. Missing metrics still drop out cleanly. - Drop the now-unused per-metric weights from the METRICS table and the scored entries (nothing rendered them; only the roll-up read them). - views.SCORE_VER s4 -> s5 so cached weighted scores are recomputed. - score.js: the hero note now reads "Every metric counts equally" instead of "Temperature, feels-like and humidity are weighted most". Verified against Seattle: overall mad equals the equal mean of the eight metric mads (7.9), and the entries no longer carry a weight. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8 --- data/scoring.py | 43 +++++++++++++++++++------------------- tests/data/test_scoring.py | 16 ++++++++++++-- web/views.py | 2 +- 3 files changed, 36 insertions(+), 25 deletions(-) diff --git a/data/scoring.py b/data/scoring.py index 1dae1bd..240ea9b 100644 --- a/data/scoring.py +++ b/data/scoring.py @@ -7,8 +7,8 @@ between where it lands and ``q`` — in percentile points, so it is unit-free an comparable across metrics — is the divergence. A metric's score is the mean absolute divergence over the categories, mapped to 0-100; its sign (bias) says which way it drifted. Metrics are computed per meteorological season and the -seasonal differentials are averaged into an annual view, then weighted (temps / -humidity / feels-like heaviest) into one overall score. +seasonal differentials are averaged into an annual view, then averaged equally +across all metrics into one overall score. Baseline is the ENTIRE record, including the recent years — the same all-years climatology every other view grades against. The recent window's ~13% overlap @@ -26,21 +26,20 @@ SEASONS = {"djf": (12, 1, 2), "mam": (3, 4, 5), "jja": (6, 7, 8), "son": (9, 10, 11)} SLICES = ("annual", *SEASONS) # payload slice keys -# Per-metric descriptor — display label, overall-score weight, and the -# (positive-drift, negative-drift) direction words — in ONE table so a scored -# metric is defined in a single place. Temps / humidity / feels-like dominate the -# weighting; wet bulb medium; wind + precip lightest. wetbulb is derived at the -# read boundary (climate.py); the rest are raw daily columns. Insertion order is -# the canonical compute order (the frontend renders in its own display order). +# Per-metric descriptor — display label and the (positive-drift, negative-drift) +# direction words — in ONE table so a scored metric is defined in a single place. +# The overall score weights every metric equally (see _overall). wetbulb is derived +# at the read boundary (climate.py); the rest are raw daily columns. Insertion order +# is the canonical compute order (the frontend renders in its own display order). METRICS = { - "tmax": {"label": "High temp", "weight": 1.5, "dir": ("warmer", "cooler")}, - "tmin": {"label": "Low temp", "weight": 1.5, "dir": ("warmer", "cooler")}, - "feels": {"label": "Feels like", "weight": 2.0, "dir": ("warmer", "cooler")}, - "humid": {"label": "Humidity", "weight": 2.0, "dir": ("muggier", "drier")}, - "wetbulb": {"label": "Wet bulb", "weight": 1.25, "dir": ("warmer", "cooler")}, - "wind": {"label": "Wind", "weight": 0.5, "dir": ("windier", "calmer")}, - "gust": {"label": "Gusts", "weight": 0.5, "dir": ("gustier", "calmer")}, - "precip": {"label": "Precip", "weight": 0.75, "dir": ("wetter", "drier")}, + "tmax": {"label": "High temp", "dir": ("warmer", "cooler")}, + "tmin": {"label": "Low temp", "dir": ("warmer", "cooler")}, + "feels": {"label": "Feels like", "dir": ("warmer", "cooler")}, + "humid": {"label": "Humidity", "dir": ("muggier", "drier")}, + "wetbulb": {"label": "Wet bulb", "dir": ("warmer", "cooler")}, + "wind": {"label": "Wind", "dir": ("windier", "calmer")}, + "gust": {"label": "Gusts", "dir": ("gustier", "calmer")}, + "precip": {"label": "Precip", "dir": ("wetter", "drier")}, } SCORE_METRICS = tuple(METRICS) @@ -174,7 +173,7 @@ def _direction(metric: str, bias: float) -> str: def _null_entry(metric: str, reason: str) -> dict: return {"key": metric, "label": METRICS[metric]["label"], "score": None, - "weight": METRICS[metric]["weight"], "reason": reason} + "reason": reason} def _build_entry(metric: str, mad: float, bias: float, per_q: list, @@ -189,7 +188,7 @@ def _build_entry(metric: str, mad: float, bias: float, per_q: list, "score": score, "mad": round(mad, 1), "bias": round(bias, 1), "tier": tier, "class": css, "direction": direction, "grade": tier if score < 15 else f"{tier}, {direction}", - "weight": METRICS[metric]["weight"], "per_q": per_q, + "per_q": per_q, "n_recent": n_recent, "n_base": n_base, } if freq is not None: @@ -206,8 +205,9 @@ def _metric_entry(metric: str, base: np.ndarray, rec: np.ndarray) -> dict: def _overall(entries: dict) -> dict | None: - """Weighted roll-up of the present metrics in one slice. Weights renormalize - over whatever is present (a metric missing for the source drops out cleanly). + """Equal-weight roll-up of the present metrics in one slice: every metric (and, + through each metric's own mean, every percentile) counts the same. Metrics + missing for the source simply drop out of the average. The total reads as a direction-agnostic NET CHANGE: colored by magnitude on the intensity ramp and labeled by tier alone, not 'warmer/cooler' — the per-metric @@ -215,8 +215,7 @@ def _overall(entries: dict) -> dict | None: present = [e for e in entries.values() if e.get("score") is not None] if not present: return None - wsum = sum(e["weight"] for e in present) - mad = sum(e["weight"] * e["mad"] for e in present) / wsum + mad = sum(e["mad"] for e in present) / len(present) score = score_of(mad) tier, css = tier_of(score, 1.0) # magnitude only — net change, not a direction return {"score": score, "mad": round(mad, 1), "tier": tier, "class": css, diff --git a/tests/data/test_scoring.py b/tests/data/test_scoring.py index d617d53..949cd40 100644 --- a/tests/data/test_scoring.py +++ b/tests/data/test_scoring.py @@ -160,14 +160,26 @@ def test_wetbulb_stress_frequency_rises_with_heat(): assert freq["f6"] > freq["f45"] and freq["d"] > 0 -# --- missing metric / weight renormalization ---------------------------------- +# --- overall aggregation ------------------------------------------------------ + +def test_overall_is_equal_average_of_metric_mads(): + # Every present metric counts the same — no per-metric weighting. + entries = { + "a": {"score": 20, "mad": 3.0}, + "b": {"score": 80, "mad": 12.0}, + "c": {"score": None}, # missing -> dropped from the average + } + ov = scoring._overall(entries) + assert ov["mad"] == 7.5 # (3.0 + 12.0) / 2 + assert ov["score"] == scoring.score_of(7.5) == 50 + def test_missing_gust_column_nulls_out_but_overall_survives(): out = scoring.build_scores(make_frame(drop=("gust",))) ann = out["slices"]["annual"] g = ann["metrics"]["gust"] assert g["score"] is None and "not available" in g["reason"] - assert ann["overall"]["score"] is not None # renormalized over present metrics + assert ann["overall"]["score"] is not None # averaged over present metrics def test_all_null_metric_reports_not_enough_data(): diff --git a/web/views.py b/web/views.py index dfd94f5..82d4e35 100644 --- a/web/views.py +++ b/web/views.py @@ -108,7 +108,7 @@ def forecast_key(today, days: int) -> str: # is the plain `history_token` (expires when the archive tail advances). The key # just carries the scoring-math version, so a math change invalidates only score # rows without disturbing any other kind. -SCORE_VER = "s4" +SCORE_VER = "s5" # s5: overall is an equal average of metrics (was weighted) def score_key() -> str: