12 min read

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.

Five LCP values, two scaling functions A horizontal bar chart of the subscore assigned to five LCP measurements under linear scaling. At 1000 milliseconds linear gives 90 and log gives 96. At 2500 both give about 60. At 4000 linear gives 30 and log gives 41. At 8000 linear gives 0 and log gives 18. At 20000 both give 0. The two agree in the middle and diverge sharply in the tail, where linear collapses to zero and log keeps ranking. SUBSCORE ASSIGNED TO EACH LCP VALUE 1,000 ms linear 90 / log 96 2,500 ms linear 60 / log 60 4,000 ms linear 30 / log 41 8,000 ms linear 0 / log 18 20,000 ms linear 0 / log 0 The two agree where most pages are and diverge in the tail. That is the whole decision: whether pages past the threshold should still be rankable against each other, or should all be equally bad.

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. good and poor mean the same thing under every curve, so switching a metric between them changes the shape and not the thresholds — which keeps the change reviewable.
  • logarithmic clamps 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.
Which scaling a metric earns A question card asks what the metric distribution looks like and what the subscore is used for, with three outcomes. A bounded metric on a roughly even distribution takes linear scaling, which is easy to explain and behaves predictably. A heavily right-skewed metric where the tail still needs ranking takes logarithmic scaling, so a nine-second page still scores worse than an eight-second one. A metric with official banded thresholds takes piecewise scaling anchored on those bands, so the score agrees with the published classification. How is this metric distributed, and what is the subscore used for? bounded, roughly even Linear predictable, explainable, and behaves as reviewers expect right-skewed, tail matters Logarithmic keeps ranking pages that are all past the threshold official banded thresholds Piecewise anchored on the published bands so the two agree Piecewise is the right default for Core Web Vitals specifically, because the published Good, Needs Improvement and Poor bounds already encode the intended shape and a smooth curve will disagree with them.

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

Three ways scaling distorts a composite Three rows. Linear scaling with a hard floor collapses everything past the cap to zero, so a page that is twice as slow as another scores identically. Logarithmic scaling on a metric whose good values approach zero produces an infinite subscore unless an epsilon is added. And changing the scaling function without versioning the score produces a step change in the trend that looks exactly like a site regression. SYMPTOM ROOT CAUSE FIX Two very different pages score identically at the bottom Linear with a hard floor everything past the cap maps to exactly zero Use log in the tail or extend the cap so the range covers real worst cases A subscore comes out infinite or NaN on the fastest pages log of a value near zero the metric legitimately reaches values the function cannot take Add an epsilon and clamp the input to a small positive minimum The trend has a step change with no deploy and no incident The scaling function changed every historical score is now on a different curve Version the score and refuse comparisons across the boundary until history is rescored The third is the expensive one, because it produces a regression report that is entirely an artefact of the scoring change and will be investigated as though it were real.

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.