Score every metric equally in the overall total (#218)

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
This commit is contained in:
Emi Griffith 2026-07-19 22:45:57 -07:00 committed by GitHub
parent d17ac794fd
commit 789dcb03d4
3 changed files with 36 additions and 25 deletions

View file

@ -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 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 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 which way it drifted. Metrics are computed per meteorological season and the
seasonal differentials are averaged into an annual view, then weighted (temps / seasonal differentials are averaged into an annual view, then averaged equally
humidity / feels-like heaviest) into one overall score. across all metrics into one overall score.
Baseline is the ENTIRE record, including the recent years the same all-years 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 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)} "jja": (6, 7, 8), "son": (9, 10, 11)}
SLICES = ("annual", *SEASONS) # payload slice keys SLICES = ("annual", *SEASONS) # payload slice keys
# Per-metric descriptor — display label, overall-score weight, and the # Per-metric descriptor — display label and the (positive-drift, negative-drift)
# (positive-drift, negative-drift) direction words — in ONE table so a scored # direction words — in ONE table so a scored metric is defined in a single place.
# metric is defined in a single place. Temps / humidity / feels-like dominate the # The overall score weights every metric equally (see _overall). wetbulb is derived
# weighting; wet bulb medium; wind + precip lightest. wetbulb is derived at the # at the read boundary (climate.py); the rest are raw daily columns. Insertion order
# read boundary (climate.py); the rest are raw daily columns. Insertion order is # is the canonical compute order (the frontend renders in its own display order).
# the canonical compute order (the frontend renders in its own display order).
METRICS = { METRICS = {
"tmax": {"label": "High temp", "weight": 1.5, "dir": ("warmer", "cooler")}, "tmax": {"label": "High temp", "dir": ("warmer", "cooler")},
"tmin": {"label": "Low temp", "weight": 1.5, "dir": ("warmer", "cooler")}, "tmin": {"label": "Low temp", "dir": ("warmer", "cooler")},
"feels": {"label": "Feels like", "weight": 2.0, "dir": ("warmer", "cooler")}, "feels": {"label": "Feels like", "dir": ("warmer", "cooler")},
"humid": {"label": "Humidity", "weight": 2.0, "dir": ("muggier", "drier")}, "humid": {"label": "Humidity", "dir": ("muggier", "drier")},
"wetbulb": {"label": "Wet bulb", "weight": 1.25, "dir": ("warmer", "cooler")}, "wetbulb": {"label": "Wet bulb", "dir": ("warmer", "cooler")},
"wind": {"label": "Wind", "weight": 0.5, "dir": ("windier", "calmer")}, "wind": {"label": "Wind", "dir": ("windier", "calmer")},
"gust": {"label": "Gusts", "weight": 0.5, "dir": ("gustier", "calmer")}, "gust": {"label": "Gusts", "dir": ("gustier", "calmer")},
"precip": {"label": "Precip", "weight": 0.75, "dir": ("wetter", "drier")}, "precip": {"label": "Precip", "dir": ("wetter", "drier")},
} }
SCORE_METRICS = tuple(METRICS) SCORE_METRICS = tuple(METRICS)
@ -174,7 +173,7 @@ def _direction(metric: str, bias: float) -> str:
def _null_entry(metric: str, reason: str) -> dict: def _null_entry(metric: str, reason: str) -> dict:
return {"key": metric, "label": METRICS[metric]["label"], "score": None, 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, 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), "score": score, "mad": round(mad, 1), "bias": round(bias, 1),
"tier": tier, "class": css, "direction": direction, "tier": tier, "class": css, "direction": direction,
"grade": tier if score < 15 else f"{tier}, {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, "n_recent": n_recent, "n_base": n_base,
} }
if freq is not None: 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: def _overall(entries: dict) -> dict | None:
"""Weighted roll-up of the present metrics in one slice. Weights renormalize """Equal-weight roll-up of the present metrics in one slice: every metric (and,
over whatever is present (a metric missing for the source drops out cleanly). 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 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 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] present = [e for e in entries.values() if e.get("score") is not None]
if not present: if not present:
return None return None
wsum = sum(e["weight"] for e in present) mad = sum(e["mad"] for e in present) / len(present)
mad = sum(e["weight"] * e["mad"] for e in present) / wsum
score = score_of(mad) score = score_of(mad)
tier, css = tier_of(score, 1.0) # magnitude only — net change, not a direction 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, return {"score": score, "mad": round(mad, 1), "tier": tier, "class": css,

View file

@ -160,14 +160,26 @@ def test_wetbulb_stress_frequency_rises_with_heat():
assert freq["f6"] > freq["f45"] and freq["d"] > 0 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(): def test_missing_gust_column_nulls_out_but_overall_survives():
out = scoring.build_scores(make_frame(drop=("gust",))) out = scoring.build_scores(make_frame(drop=("gust",)))
ann = out["slices"]["annual"] ann = out["slices"]["annual"]
g = ann["metrics"]["gust"] g = ann["metrics"]["gust"]
assert g["score"] is None and "not available" in g["reason"] 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(): def test_all_null_metric_reports_not_enough_data():

View file

@ -108,7 +108,7 @@ def forecast_key(today, days: int) -> str:
# is the plain `history_token` (expires when the archive tail advances). The key # 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 # just carries the scoring-math version, so a math change invalidates only score
# rows without disturbing any other kind. # 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: def score_key() -> str: