perfect-postcode/pipeline/transform/area_crime_averages.py
2026-06-25 22:29:52 +01:00

212 lines
8 KiB
Python

"""Precompute per-outcode / per-postcode-sector / national mean headline crime
counts for the right pane's area comparison.
The right pane shows each crime metric next to its area context: the mean
average-annual count (``"X (/yr, 7y)"``) across the selection's postcode sector (e.g.
``"E14 2"``), its outcode (e.g. ``"E14"``), and the nation. Crime is constant
within a postcode (the merge keys it on the postcode), so each postcode
contributes its single value weighted by how many properties sit in it — keeping
every scope on the same property-weighted basis as the per-selection mean, so the
four numbers (this selection / sector / outcode / nation) are directly
comparable. The national figure here is an EXACT property-weighted mean, which is
why it overrides the upward-biased histogram-bin national average for crime.
This used to be recomputed inside the server on every boot from the loaded
property matrix. It is a pure function of the two merge outputs, so it belongs in
the data build; the server now just loads the parquet this writes. Reading the
crime values from ``postcode.parquet`` and the per-postcode property weights from
``properties.parquet`` mirrors exactly the two inputs the server loads, so the
result matches what the server used to compute (minus its u16 quantization loss).
Output schema — one row per area:
scope : ``"national"`` | ``"outcode"`` | ``"sector"``
area : the outcode (``"E14"``) / sector (``"E14 2"``);
``""`` for the single national row
``<type> (/yr, 7y|2y)`` : Float32 property-weighted mean crime count per year
(null = the scope has no data for that type)
"""
import argparse
from pathlib import Path
import polars as pl
# Filterable crime columns are the average-annual incident counts and carry this
# marker in postcode.parquet (e.g. "Burglary (/yr, 7y)"). We average those. The
# full column name is kept; the server discovers and keys area averages by the
# same names.
COUNT_MARKER = " (/yr, "
# `scope` discriminator values. The server's loader dispatches on these.
SCOPE_NATIONAL = "national"
SCOPE_OUTCODE = "outcode"
SCOPE_SECTOR = "sector"
# Area label on the national row — it spans the whole country, so it has no code.
NATIONAL_AREA = ""
# Both merge outputs key on the canonical NSPL `pcds` postcode (spaced, e.g.
# "E14 2DG").
POSTCODE_COLUMN = "Postcode"
# Internal weight / split columns dropped before write.
_WEIGHT_COLUMN = "_weight"
_OUTCODE_COLUMN = "_outcode"
_SECTOR_COLUMN = "_sector"
def _crime_columns(columns: list[str]) -> list[str]:
crime_cols = [name for name in columns if COUNT_MARKER in name]
if not crime_cols:
raise ValueError(
f"postcode parquet has no '*{COUNT_MARKER}*' crime count columns to average"
)
return crime_cols
def _weighted_mean(column: str) -> pl.Expr:
"""Property-weighted mean of ``column`` that excludes nulls from BOTH the
value sum and the weight.
A null crime value is a genuine gap (the postcode's police force published no
usable data), not zero crime, so it must dilute neither the numerator nor the
denominator — exactly as the server's former estimator skipped NaN values.
Yields null when no postcode in the group has data for this type.
"""
weight = pl.col(_WEIGHT_COLUMN)
numerator = (pl.col(column) * weight).sum()
denominator = weight.filter(pl.col(column).is_not_null()).sum()
return (
pl.when(denominator > 0)
.then(numerator / denominator)
.otherwise(None)
.cast(pl.Float32)
.alias(column)
)
def compute_area_crime_averages(
postcodes: pl.LazyFrame, properties: pl.LazyFrame
) -> pl.DataFrame:
"""Build the national / per-outcode / per-sector crime-average table.
``postcodes`` is the merge's postcode output (one row per active postcode,
carrying the ``"* (/yr, *)"`` crime count columns); ``properties`` is the merge's
per-property output, used only to weight each postcode by its property count.
"""
crime_cols = _crime_columns(postcodes.collect_schema().names())
# Property weight per postcode = how many property rows the server indexes
# under it. The inner join keeps only postcodes that actually carry
# properties, matching the server's per-postcode row index (a postcode with
# no properties never contributed to any average).
weights = properties.group_by(POSTCODE_COLUMN).agg(pl.len().alias(_WEIGHT_COLUMN))
# Outcode / sector of the spaced `pcds` postcode, matching the server's
# postcode_outcode / postcode_sector (split on the single space; sector =
# outcode + space + first inward character). Null where the form has no
# inward code, so such rows drop out of the per-area groups.
parts = pl.col(POSTCODE_COLUMN).str.splitn(" ", 2).struct
outward = parts.field("field_0")
inward = parts.field("field_1")
base = (
postcodes.select(POSTCODE_COLUMN, *crime_cols)
.join(weights, on=POSTCODE_COLUMN, how="inner")
.with_columns(
pl.when(inward.is_not_null())
.then(outward)
.otherwise(None)
.alias(_OUTCODE_COLUMN),
pl.when(inward.str.len_chars() >= 1)
.then(outward + pl.lit(" ") + inward.str.slice(0, 1))
.otherwise(None)
.alias(_SECTOR_COLUMN),
)
)
mean_exprs = [_weighted_mean(column) for column in crime_cols]
national = base.select(
pl.lit(SCOPE_NATIONAL).alias("scope"),
pl.lit(NATIONAL_AREA).alias("area"),
*mean_exprs,
)
by_outcode = (
base.drop_nulls(_OUTCODE_COLUMN)
.group_by(_OUTCODE_COLUMN)
.agg(mean_exprs)
.select(
pl.lit(SCOPE_OUTCODE).alias("scope"),
pl.col(_OUTCODE_COLUMN).alias("area"),
*crime_cols,
)
)
by_sector = (
base.drop_nulls(_SECTOR_COLUMN)
.group_by(_SECTOR_COLUMN)
.agg(mean_exprs)
.select(
pl.lit(SCOPE_SECTOR).alias("scope"),
pl.col(_SECTOR_COLUMN).alias("area"),
*crime_cols,
)
)
result = pl.concat([national, by_outcode, by_sector], how="vertical").collect(
engine="streaming"
)
# Drop per-area rows where every crime type is null: the server only created
# a map entry once a scope had at least one finite value, so an all-null
# outcode/sector reported no code at all. The national row is always kept (it
# always has data, and is emitted even for areas absent from both maps).
has_any = pl.any_horizontal(pl.col(column).is_not_null() for column in crime_cols)
return result.filter((pl.col("scope") == SCOPE_NATIONAL) | has_any)
def main() -> None:
parser = argparse.ArgumentParser(
description=(
"Precompute national / per-outcode / per-sector mean headline crime "
"counts from the merge outputs"
)
)
parser.add_argument(
"--postcodes",
type=Path,
required=True,
help="postcode.parquet (area features, incl. the '* (/yr, *)' crime columns)",
)
parser.add_argument(
"--properties",
type=Path,
required=True,
help="properties.parquet (per-property rows; supplies postcode property weights)",
)
parser.add_argument(
"--output",
type=Path,
required=True,
help="Output area_crime_averages.parquet path",
)
args = parser.parse_args()
result = compute_area_crime_averages(
pl.scan_parquet(args.postcodes), pl.scan_parquet(args.properties)
)
outcodes = result.filter(pl.col("scope") == SCOPE_OUTCODE).height
sectors = result.filter(pl.col("scope") == SCOPE_SECTOR).height
print(
f"Area crime averages: {result.height} rows "
f"({outcodes} outcodes, {sectors} sectors, "
f"{len(_crime_columns(result.columns))} crime types)"
)
args.output.parent.mkdir(parents=True, exist_ok=True)
result.write_parquet(args.output, compression="zstd")
print(f"Saved to {args.output}")
if __name__ == "__main__":
main()