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
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,

View file

@ -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():

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
# 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: