212 lines
8 KiB
Python
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()
|