Choosing Linear vs Log Scaling for Metric Subscores
Every composite health score needs raw measurements turned into comparable subscores, and the function that does it decides more about the resulting number than the weights do. Linear scaling is the default almost everyone reaches for; on right-skewed latency data it throws away exactly the distinctions that matter most. This page is part of Designing Custom Health Score Algorithms and covers choosing between linear, logarithmic and piecewise scaling per metric.
Environment isolation and dependency declaration
set -euo pipefail
export SCALE_CONFIG="/opt/audit/config/metric_scaling.json" # versioned in git
export SCALE_EPSILON=1e-6 # guards log() against a zero input
export SCORING_VERSION="2026.07.3" # bumped whenever a curve changes
/opt/audit/.venv/bin/pip install "numpy==1.26.4"
SCORING_VERSION being bumped on any curve change is not bookkeeping. A scaling function is part of the score definition, so changing one makes every historical value incomparable — and the resulting step in the trend chart is indistinguishable from a real regression unless the version boundary is recorded.
Implementation
#!/usr/bin/env python3
# /opt/audit/scoring/scaling.py — three curves, one interface, all clamped.
from __future__ import annotations
import math
import os
EPS = float(os.environ.get("SCALE_EPSILON", "1e-6"))
def linear(value: float, good: float, poor: float) -> float:
"""100 at or below `good`, 0 at or above `poor`, straight line between.
Predictable and explainable; discards all distinction past `poor`."""
if value <= good:
return 100.0
if value >= poor:
return 0.0
return 100.0 * (poor - value) / (poor - good)
def logarithmic(value: float, good: float, poor: float) -> float:
"""Same anchors, but the curve compresses the tail so pages beyond
`poor` still rank against each other instead of all scoring zero."""
v = max(value, EPS)
g, p = max(good, EPS), max(poor, EPS)
if v <= g:
return 100.0
score = 100.0 * (math.log(p) - math.log(v)) / (math.log(p) - math.log(g))
return max(0.0, min(100.0, score))
def piecewise(value: float, good: float, poor: float) -> float:
"""Anchored on published bands: 100 at the Good bound, 50 at the
Needs-Improvement bound, 0 at Poor, linear within each segment. Use this
wherever an external classification already defines the bands."""
if value <= good:
return 100.0
if value >= poor:
return 0.0
mid = (good + poor) / 2 if poor > good else good
if value <= mid:
return 100.0 - 50.0 * (value - good) / (mid - good)
return 50.0 - 50.0 * (value - mid) / (poor - mid)
CURVES = {"linear": linear, "logarithmic": logarithmic, "piecewise": piecewise}
def subscore(metric: str, value: float, config: dict) -> float:
spec = config[metric]
fn = CURVES[spec["curve"]]
return round(fn(float(value), spec["good"], spec["poor"]), 2)
The decisions worth noting:
- All three share the same anchors.
goodandpoormean the same thing under every curve, so switching a metric between them changes the shape and not the thresholds — which keeps the change reviewable. logarithmicclamps its inputs above zero. A metric that legitimately reaches zero — cumulative layout shift on a perfect page — produces a domain error otherwise, and the failure appears on the best pages rather than the worst.- Every curve clamps its output to 0–100. Floating-point error at the anchors otherwise produces subscores fractionally outside the range, which then propagate into a composite that a downstream assertion rejects.
- The curve is per metric, in config. Latency metrics and ratio metrics rarely want the same shape, and putting the choice in a versioned file makes it a reviewable decision rather than a constant buried in the scorer.
Verification and smoke test
/opt/audit/.venv/bin/python3 - <<'PY'
from scaling import linear, logarithmic, piecewise
# 1. All curves agree at the anchors.
for fn in (linear, logarithmic, piecewise):
assert fn(2500, 2500, 4000) == 100.0, fn.__name__
assert fn(4000, 2500, 4000) == 0.0, fn.__name__
print("PASS: every curve agrees at good and poor")
# 2. Monotonicity: a worse value never scores higher.
for fn in (linear, logarithmic, piecewise):
xs = [1000, 2000, 2500, 3000, 3500, 4000, 8000]
ys = [fn(x, 2500, 4000) for x in xs]
assert all(a >= b for a, b in zip(ys, ys[1:])), (fn.__name__, ys)
print("PASS: every curve is monotonic")
# 3. Log keeps ranking past the poor bound where linear does not.
assert linear(8000, 2500, 4000) == linear(20000, 2500, 4000) == 0.0
assert logarithmic(8000, 2500, 4000) > logarithmic(20000, 2500, 4000)
print("PASS: log still separates values past the poor bound")
# 4. No curve produces NaN or a value outside 0-100.
for fn in (linear, logarithmic, piecewise):
for x in (0, 1e-9, 2500, 1e9):
s = fn(x, 2500, 4000)
assert 0.0 <= s <= 100.0 and s == s, (fn.__name__, x, s)
print("PASS: all outputs bounded and finite")
PY
Expected output is four PASS lines. The monotonicity assertion is the one worth keeping permanently: it is the property that makes a subscore defensible in a triage meeting, and it is what breaks first when somebody adjusts a curve by hand.
Failure modes
A composite improves after a scaling change and nobody trusts it
Correctly so. Any curve change rewrites every score, so the movement is an artefact of the change rather than of the site. Bump SCORING_VERSION, rescore a full history window under the new curve, and present the comparison only within one version — exactly as tracking metric trends across release cycles requires.
Log scaling makes almost every page look acceptable
The curve compresses the tail by design, so a site whose problems are overwhelmingly in the tail will look better under it. That is a reason to choose the curve deliberately rather than for smoothness: if the operational question is "how many pages are past the threshold", a piecewise curve anchored on the published bands answers it and a logarithmic one blurs it.
Two metrics with the same weight contribute unequally
They are on different curves, so a weight of 0.35 buys a different amount of movement in each. Weights are only comparable across metrics scaled the same way; if curves differ deliberately, say so in the config comment and expect to tune the weights alongside them.
FAQ
When is linear scaling the wrong choice?
When the metric is heavily right-skewed and the tail still needs ranking. Linear scaling assigns zero to everything past the poor bound, so a page taking eight seconds and one taking twenty seconds score identically — which means remediation cannot be prioritised between them and an improvement from twenty to nine seconds shows no movement at all. Latency metrics almost always have that shape, which is why the default choice is frequently the wrong one.
Why use piecewise scaling for Core Web Vitals specifically?
Because the published Good, Needs Improvement and Poor bounds already encode an intended shape, and a smooth curve will disagree with the classification anyone else is using. Anchoring at 100, 50 and 0 on those bounds means a page classified as Needs Improvement scores near 50 in your composite, so the internal number and the external classification tell the same story. A smooth curve makes them diverge in a way that is tedious to explain in every review.
Does changing a scaling curve require rescoring history?
Yes, and refusing comparisons across the boundary until it is done. A curve change rewrites every subscore, so the trend line acquires a step change that looks exactly like a site regression and will be investigated as one. Bump the scoring version, rescore a full baseline window under the new curve, and have the trend chart annotate the boundary so a comparison that crosses it can be refused rather than silently produced.
Related
- Designing Custom Health Score Algorithms — the parent guide covering weighting, normalisation and the composite pipeline
- How to Weight Core Web Vitals in Custom Dashboards — why normalisation has to happen before weighting, whichever curve is used
- Interpreting Core Web Vitals Threshold Tables — the published bands a piecewise curve should anchor on