Download src/features.py from Karar-git/hack4dev-building-age-winners: direct link, hf CLI and curl.
- Browser
- Download file 28.9 kB
-
https://huggingface.co/Karar-git/hack4dev-building-age-winners/resolve/main/src/features.py
- Command line
-
hf download hf://Karar-git/hack4dev-building-age-winners/src/features.py
-
curl -L -o features.py https://huggingface.co/Karar-git/hack4dev-building-age-winners/resolve/main/src/features.py
28.9 kB
| """ | |
| Raw wide-format Landsat parquet -> one feature row per 30 m pixel. | |
| Two feature sets come out of here: | |
| * the OFFICIAL 60 - a faithful re-implementation of the organisers' | |
| Notebook 3, so our numbers stay comparable with the published baseline; | |
| * the EXTENDED set - physically scaled surface reflectance, correct spectral | |
| indices, construction-event ("step") detection, per-decade means, trends | |
| and early/late contrasts. This is what the LLM verbaliser and the strong | |
| GBDT models actually use. | |
| Everything is vectorised over a (n_pixels x n_years) matrix per band, so a whole | |
| city is processed in a couple of minutes on a laptop CPU. | |
| """ | |
| from __future__ import annotations | |
| import warnings | |
| import numpy as np | |
| import pandas as pd | |
| from config import (BASE_BANDS, BLUE_MAX, CLASS_EVENTS, EARLY_CUTOFF, | |
| FEATURE_COLS_60, INDEX_COLS, LATE_START, PID, SR_OFFSET, | |
| SR_SCALE) | |
| EPS = 1e-6 | |
| # --------------------------------------------------------------------------- # | |
| # Stage 1 - load, label, flatten, gap-fill (official logic) | |
| # --------------------------------------------------------------------------- # | |
| def assign_age_class(df: pd.DataFrame) -> pd.DataFrame: | |
| """Bin weighted_mean_year into classes 1-4 with city-specific boundaries.""" | |
| out = df | |
| out["age_class"] = pd.Series(pd.NA, index=out.index, dtype="Int64") | |
| for city, ev in CLASS_EVENTS.items(): | |
| mask = (out["city"] == city).to_numpy() | |
| if mask.any(): | |
| binned = pd.cut(out.loc[mask, "weighted_mean_year"], | |
| bins=[-np.inf, ev, 1984, 2004, np.inf], | |
| labels=[1, 2, 3, 4]) | |
| out.loc[mask, "age_class"] = binned.astype("Int64") | |
| return out | |
| def make_flat_view(df: pd.DataFrame, agg: str = "first") -> pd.DataFrame: | |
| """ | |
| One value per (pixel, year) per band; drop haze-contaminated rows. | |
| agg="first" : the first QA-valid observation, exactly as the organisers do. | |
| agg="median" : the median of all QA-valid observations in that year. With up | |
| to three summer scenes per year, a single undetected thin | |
| cloud moves a yearly value by more than any real surface | |
| change, so the median is the more robust choice - use it for | |
| our own features and keep "first" to reproduce the official | |
| 60 exactly. | |
| """ | |
| keep = [c for c in ["city", "year", "px_key", "py_key", "coverage", | |
| "weighted_mean_year", "age_class"] if c in df.columns] | |
| out = df[keep].copy() | |
| n = len(df) | |
| # Which satellite supplied the observation we end up using. | |
| # | |
| # This matters more than it looks. Reading scene_id (the dataset_* string | |
| # column disagrees with it and is not to be trusted), the satellite chosen | |
| # for a given year DIFFERS BETWEEN THE TWO CITIES in 11 of the 25 years | |
| # from 2001 on: Madrid's late record is largely Landsat 7 ETM+, which has | |
| # been SLC-off and striped since 2003, while Amsterdam's is largely | |
| # Landsat 8/9 OLI. So part of any early-vs-late or year-to-year feature | |
| # measures which instrument flew over, not what the building did - and that | |
| # part is city-specific, which is the worst kind of nuisance here. Carrying | |
| # the sensor through lets a model condition on it instead of confounding | |
| # through it, and lets us measure how much it costs. | |
| sat_code = np.zeros(n, dtype=np.int8) # 0 unknown, 1 TM, 2 ETM+, 3 OLI | |
| _MAP = {"LT04": 1, "LT05": 1, "LE07": 2, "LC08": 3, "LC09": 3} | |
| assigned = np.zeros(n, dtype=bool) | |
| for slot in (1, 2, 3): | |
| sc, qc = f"scene_id_{slot}", f"qa_valid_{slot}" | |
| if sc not in df.columns or qc not in df.columns: | |
| continue | |
| valid = df[qc].fillna(False).to_numpy(dtype=bool) | |
| codes = (df[sc].astype("string").str.slice(0, 4) | |
| .map(_MAP).fillna(0).to_numpy(dtype=np.int8)) | |
| take = valid & ~assigned & (codes > 0) | |
| sat_code[take] = codes[take] | |
| assigned |= take | |
| out["sat_code"] = sat_code | |
| for band in BASE_BANDS: | |
| if agg == "median": | |
| stack = [] | |
| for slot in (1, 2, 3): | |
| bc, qc = f"{band}_{slot}", f"qa_valid_{slot}" | |
| if bc not in df.columns or qc not in df.columns: | |
| continue | |
| v = np.asarray(df[bc], dtype=np.float32).copy() | |
| ok = df[qc].fillna(False).to_numpy(dtype=bool) | |
| v[~ok] = np.nan | |
| stack.append(v) | |
| if stack: | |
| with warnings.catch_warnings(): # all-NaN rows are expected | |
| warnings.simplefilter("ignore", RuntimeWarning) | |
| col = np.nanmedian(np.stack(stack), axis=0).astype(np.float32) | |
| else: | |
| col = np.full(n, np.nan, dtype=np.float32) | |
| else: | |
| col = np.full(n, np.nan, dtype=np.float32) | |
| for slot in (1, 2, 3): | |
| bc, qc = f"{band}_{slot}", f"qa_valid_{slot}" | |
| if bc not in df.columns or qc not in df.columns: | |
| continue | |
| valid = df[qc].fillna(False).to_numpy(dtype=bool) | |
| vals = np.asarray(df[bc], dtype=np.float32) | |
| take = np.isnan(col) & valid & ~np.isnan(vals) | |
| col[take] = vals[take] | |
| out[band] = col | |
| out = out.dropna(subset=BASE_BANDS) | |
| before = len(out) | |
| out = out[out["Blue"] <= BLUE_MAX] | |
| removed = before - len(out) | |
| if removed: | |
| print(f" removed {removed:,} haze rows (Blue > {BLUE_MAX:,})") | |
| return out | |
| def period_coverage(flat: pd.DataFrame) -> pd.DataFrame: | |
| """has_early_data / has_late_data, computed on OBSERVED years (pre gap-fill).""" | |
| pixels = flat[PID].drop_duplicates() | |
| early = flat.loc[flat["year"] <= EARLY_CUTOFF, PID].drop_duplicates().assign(has_early_data=1.0) | |
| late = flat.loc[flat["year"] >= LATE_START, PID].drop_duplicates().assign(has_late_data=1.0) | |
| return (pixels.merge(early, on=PID, how="left") | |
| .merge(late, on=PID, how="left") | |
| .fillna({"has_early_data": 0.0, "has_late_data": 0.0})) | |
| def to_year_matrices(flat: pd.DataFrame): | |
| """ | |
| Pivot the observed rows into one (n_pixels x n_years) matrix per band and | |
| gap-fill exactly as the organisers do: linear interior, nearest at the edges. | |
| Returns (pixel index DataFrame, years array, {band: matrix}, observed mask) | |
| """ | |
| years = np.arange(int(flat["year"].min()), int(flat["year"].max()) + 1) | |
| pix = flat[PID].drop_duplicates().sort_values(PID).reset_index(drop=True) | |
| pix_pos = pd.Series(np.arange(len(pix)), index=pd.MultiIndex.from_frame(pix)) | |
| ri = pix_pos.loc[pd.MultiIndex.from_frame(flat[PID])].to_numpy() | |
| ci = (flat["year"].to_numpy() - years[0]).astype(np.int32) | |
| mats = {} | |
| observed = np.zeros((len(pix), len(years)), dtype=bool) | |
| observed[ri, ci] = True | |
| sat = np.zeros((len(pix), len(years)), dtype=np.int8) | |
| if "sat_code" in flat.columns: | |
| sat[ri, ci] = flat["sat_code"].to_numpy(dtype=np.int8) | |
| for band in BASE_BANDS: | |
| M = np.full((len(pix), len(years)), np.nan, dtype=np.float32) | |
| M[ri, ci] = flat[band].to_numpy(dtype=np.float32) | |
| M = (pd.DataFrame(M).interpolate(axis=1, method="linear") | |
| .bfill(axis=1).ffill(axis=1).to_numpy(dtype=np.float32)) | |
| mats[band] = M | |
| return pix, years, mats, observed, sat | |
| # --------------------------------------------------------------------------- # | |
| # Stage 2 - the official 60 features, from DN values | |
| # --------------------------------------------------------------------------- # | |
| def _indices_from(mats: dict) -> dict: | |
| """The organisers' five indices, exactly as their Notebook 3 defines them.""" | |
| B, G, R = mats["Blue"], mats["Green"], mats["Red"] | |
| N, S1, S2 = mats["NIR"], mats["SWIR1"], mats["SWIR2"] | |
| return { | |
| "NDVI": (N - R) / (N + R + EPS), | |
| "NDBI": (S1 - N) / (S1 + N + EPS), | |
| "UI": (S2 - N) / (S2 + N + EPS), | |
| "MNDWI": (G - S1) / (G + S1 + EPS), | |
| "BSI": ((S1 + R) - (N + B)) / ((S1 + R) + (N + B) + EPS), | |
| } | |
| # Tasseled-cap coefficients for OLI surface reflectance (Baig et al. 2014). | |
| # Wetness in particular separates impervious surfaces from soil better than | |
| # any of the five indices above. | |
| TC_COEF = { | |
| "TCB": (0.3029, 0.2786, 0.4733, 0.5599, 0.5080, 0.1872), | |
| "TCG": (-0.2941, -0.2430, -0.5424, 0.7276, 0.0713, -0.1608), | |
| "TCW": (0.1511, 0.1973, 0.3283, 0.3407, -0.7117, -0.4559), | |
| } | |
| def _extra_indices_from(sr: dict) -> dict: | |
| """NBR, tasseled cap, and chromaticity (band / total brightness). | |
| Chromaticity matters for transfer: dividing each band by the pixel's own | |
| 6-band sum removes albedo, illumination and the city-level brightness | |
| offset entirely, and keeps only the spectral SHAPE. | |
| """ | |
| order = [sr[b] for b in BASE_BANDS] | |
| total = sum(order) + EPS | |
| out = {"NBR": (sr["NIR"] - sr["SWIR2"]) / (sr["NIR"] + sr["SWIR2"] + EPS)} | |
| for name, coef in TC_COEF.items(): | |
| out[name] = sum(c * m for c, m in zip(coef, order)) | |
| for b in BASE_BANDS: | |
| out[f"chrom{b}"] = sr[b] / total | |
| return out | |
| def _median_filter3(M: np.ndarray) -> np.ndarray: | |
| """ | |
| 3-point running median along the time axis. | |
| One undetected thin cloud moves a yearly value by more than any real | |
| surface change, so the change detector runs on the filtered series while | |
| the reported statistics stay on the raw one. | |
| """ | |
| a = np.pad(M, ((0, 0), (1, 1)), mode="edge") | |
| return np.median(np.stack([a[:, :-2], a[:, 1:-1], a[:, 2:]]), axis=0) | |
| def build_official_60(pix, years, mats, indicators) -> pd.DataFrame: | |
| """Reproduce the organisers' 60 features (DN units, ddof=1 std).""" | |
| idx = _indices_from(mats) | |
| early = years <= EARLY_CUTOFF | |
| late = years >= LATE_START | |
| out = {} | |
| for b in BASE_BANDS: | |
| M = mats[b] | |
| out[f"{b}_mean"] = M.mean(1) | |
| out[f"{b}_std"] = M.std(1, ddof=1) | |
| out[f"{b}_early_mean"] = M[:, early].mean(1) | |
| out[f"{b}_early_std"] = M[:, early].std(1, ddof=1) | |
| out[f"{b}_late_mean"] = M[:, late].mean(1) | |
| out[f"{b}_late_std"] = M[:, late].std(1, ddof=1) | |
| D = np.diff(M, axis=1) | |
| out[f"d_{b}_mean"] = D.mean(1) | |
| out[f"d_{b}_std"] = D.std(1, ddof=1) | |
| for i in INDEX_COLS: | |
| out[f"{i}_mean"] = idx[i].mean(1) | |
| out[f"{i}_std"] = idx[i].std(1, ddof=1) | |
| df = pd.concat([pix, pd.DataFrame(out, index=pix.index)], axis=1) | |
| df = df.merge(indicators, on=PID, how="left") | |
| return df[PID + FEATURE_COLS_60] | |
| # --------------------------------------------------------------------------- # | |
| # Stage 3 - extended, physically-scaled features | |
| # --------------------------------------------------------------------------- # | |
| DECADES = [(1984, 1993), (1994, 2003), (2004, 2013), (2014, 2024)] | |
| DECADE_TAGS = ["84_93", "94_03", "04_13", "14_24"] | |
| STEP_SIGNALS = ["brightness", "SWIR1", "NDVI"] | |
| def _slope_per_decade(M: np.ndarray, years: np.ndarray) -> np.ndarray: | |
| """OLS slope of each row against year, expressed per decade.""" | |
| t = (years - years.mean()).astype(np.float32) | |
| denom = float((t * t).sum()) | |
| return (M * t).sum(1) / denom * 10.0 | |
| def _step_stats(M: np.ndarray, years: np.ndarray, win: int = 3): | |
| """ | |
| Construction-event detector. For every candidate break year, compare the | |
| mean of the `win` years before with the mean of the `win` years after. | |
| Returns (year of the largest absolute jump, signed size of that jump, | |
| number of year-on-year jumps above 3 x MAD). | |
| """ | |
| n, T = M.shape | |
| cs = np.concatenate([np.zeros((n, 1), np.float32), | |
| np.cumsum(M, axis=1, dtype=np.float32)], axis=1) | |
| def win_mean(a, b): | |
| return (cs[:, b] - cs[:, a]) / np.float32(b - a) | |
| cuts = np.arange(win, T - win + 1) | |
| diffs = np.stack([win_mean(c, c + win) - win_mean(c - win, c) for c in cuts], axis=1) | |
| # A two-sample t statistic, not just the raw difference. | |
| # | |
| # argmax over candidate years ALWAYS returns something, so a pixel that was | |
| # never rebuilt still gets a "biggest step" - and inspection caught the | |
| # verbaliser telling the model that a building from 1924 changed abruptly in | |
| # 1998. What distinguishes a real construction event is the size of the step | |
| # RELATIVE TO the scatter within the two windows being compared, which is | |
| # exactly a t statistic. The earlier version divided by the scatter about | |
| # the whole-series mean, which the step itself inflates, so it could not | |
| # separate the two cases. | |
| cs2 = np.concatenate([np.zeros((n, 1), np.float32), | |
| np.cumsum(M.astype(np.float32) ** 2, axis=1, dtype=np.float32)], | |
| axis=1) | |
| def win_var(a, b): | |
| k = np.float32(b - a) | |
| mu = (cs[:, b] - cs[:, a]) / k | |
| ex2 = (cs2[:, b] - cs2[:, a]) / k | |
| return np.maximum(ex2 - mu * mu, 0.0) * (k / max(k - 1.0, 1.0)) | |
| tstats = np.empty_like(diffs) | |
| for m_i, c in enumerate(cuts): | |
| vb, va = win_var(c - win, c), win_var(c, c + win) | |
| pooled = np.sqrt(np.maximum((vb + va) / 2.0, 0.0) * (2.0 / win)) + EPS | |
| tstats[:, m_i] = diffs[:, m_i] / pooled | |
| # The break year is chosen by the largest MAGNITUDE, not the largest t. | |
| # | |
| # Selecting on t was tried and was a disaster: Amsterdam's agreement with | |
| # the true construction year fell from 69.4% within five years to 31.5%, | |
| # and Madrid's from 44.6% to 28.3%. The t statistic is maximised wherever | |
| # the pooled variance of a 3-year window happens to be near zero, which is | |
| # noise, not a construction event. The magnitude is the signal. | |
| # | |
| # The t value at that same break is still reported as a feature, where it | |
| # means "how large is this shift relative to the local scatter" and the | |
| # trees can use it or ignore it. It is NOT used for any wording in the | |
| # prompt - see the note in verbalize.render_prompt. | |
| k = np.argmax(np.abs(diffs), axis=1) | |
| rows = np.arange(n) | |
| step_mag = diffs[rows, k] | |
| step_t = np.abs(tstats[rows, k]) | |
| step_year = years[cuts[k]].astype(np.float32) | |
| D = np.diff(M, axis=1) | |
| mad = np.median(np.abs(D - np.median(D, axis=1, keepdims=True)), axis=1) + EPS | |
| n_jumps = (np.abs(D) > (3.0 * mad[:, None])).sum(1).astype(np.float32) | |
| return step_year, step_mag, n_jumps, step_t | |
| SPIKE_SIGNALS = ["BSI", "SWIR1", "brightness"] | |
| # Sensor eras. The early/late split at 2004 also straddles a change of | |
| # atmospheric-correction algorithm (LEDAPS for TM/ETM+, LaSRC for OLI), and the | |
| # artefact is latitude-dependent, so part of any early/late contrast measures | |
| # which algorithm ran rather than what the building did. Splitting at the | |
| # sensor boundary as well makes that confound explicit instead of hidden. | |
| TM_ERA = (1984, 2011) | |
| OLI_ERA = (2013, 2024) | |
| SAT_NAMES = {1: "tm", 2: "etm", 3: "oli"} | |
| def sensor_features(pix, years, observed, sat) -> dict: | |
| """ | |
| Per-pixel sensor composition: which instrument actually supplied each year. | |
| Not a building property at all - which is the point. These columns let a | |
| model absorb the sensor nuisance explicitly instead of attributing it to | |
| building age, and they let us measure how much of the Madrid/Amsterdam gap | |
| is instrument rather than architecture. | |
| """ | |
| early = years <= EARLY_CUTOFF | |
| late = years >= LATE_START | |
| out = {} | |
| n_obs = np.clip(observed.sum(1), 1, None).astype(np.float32) | |
| for code, nm in SAT_NAMES.items(): | |
| hit = (sat == code) | |
| out[f"sat_share_{nm}"] = hit.sum(1) / n_obs | |
| out[f"sat_share_{nm}_early"] = (hit & early[None, :]).sum(1) / np.clip( | |
| (observed & early[None, :]).sum(1), 1, None) | |
| out[f"sat_share_{nm}_late"] = (hit & late[None, :]).sum(1) / np.clip( | |
| (observed & late[None, :]).sum(1), 1, None) | |
| # how often the instrument changes from one observed year to the next | |
| switches = np.zeros(len(sat), dtype=np.float32) | |
| for i in range(sat.shape[0]): | |
| s = sat[i][observed[i]] | |
| s = s[s > 0] | |
| if len(s) > 1: | |
| switches[i] = float((s[1:] != s[:-1]).sum()) | |
| out["sat_switches"] = switches | |
| return {k: np.asarray(v, dtype=np.float32) for k, v in out.items()} | |
| def build_extended(pix, years, mats_dn, observed, sat=None) -> pd.DataFrame: | |
| """Surface-reflectance features: correct indices, trends, steps, decades.""" | |
| sr = {b: mats_dn[b] * np.float32(SR_SCALE) + np.float32(SR_OFFSET) | |
| for b in BASE_BANDS} | |
| idx = _indices_from(sr) | |
| extra = _extra_indices_from(sr) | |
| bright = np.mean(np.stack([sr[b] for b in BASE_BANDS]), axis=0, dtype=np.float32) | |
| signals = {"brightness": bright} | |
| signals.update(sr) | |
| signals.update(idx) | |
| signals.update(extra) | |
| early = years <= EARLY_CUTOFF | |
| late = years >= LATE_START | |
| tm = (years >= TM_ERA[0]) & (years <= TM_ERA[1]) | |
| oli = (years >= OLI_ERA[0]) & (years <= OLI_ERA[1]) | |
| out = {} | |
| # Full statistics for the signals worth the columns; a short set for the | |
| # chromaticity and tasseled-cap extras, to keep the table manageable. | |
| rich = {"brightness", *BASE_BANDS, *INDEX_COLS} | |
| for name, M in signals.items(): | |
| out[f"sr_{name}_mean"] = M.mean(1) | |
| out[f"sr_{name}_std"] = M.std(1, ddof=1) | |
| out[f"sr_{name}_early_mean"] = M[:, early].mean(1) | |
| out[f"sr_{name}_late_mean"] = M[:, late].mean(1) | |
| out[f"sr_{name}_late_minus_early"] = (out[f"sr_{name}_late_mean"] | |
| - out[f"sr_{name}_early_mean"]) | |
| # std scales with brightness, so a raw std is partly a city marker; | |
| # the coefficient of variation is not. | |
| out[f"sr_{name}_cv"] = out[f"sr_{name}_std"] / (np.abs(out[f"sr_{name}_mean"]) + EPS) | |
| if name not in rich: | |
| continue | |
| out[f"sr_{name}_early_std"] = M[:, early].std(1, ddof=1) | |
| out[f"sr_{name}_late_std"] = M[:, late].std(1, ddof=1) | |
| out[f"sr_{name}_slope_decade"] = _slope_per_decade(M, years) | |
| out[f"sr_{name}_min"] = M.min(1) | |
| out[f"sr_{name}_max"] = M.max(1) | |
| out[f"sr_{name}_tm_mean"] = M[:, tm].mean(1) | |
| out[f"sr_{name}_oli_mean"] = M[:, oli].mean(1) | |
| out[f"sr_{name}_oli_minus_tm"] = out[f"sr_{name}_oli_mean"] - out[f"sr_{name}_tm_mean"] | |
| for (y0, y1), tag in zip(DECADES, DECADE_TAGS): | |
| sel = (years >= y0) & (years <= y1) | |
| out[f"sr_{name}_dec{tag}_mean"] = M[:, sel].mean(1) | |
| # ---- construction events, on the cloud-filtered series ----------------- | |
| smoothed = {k: _median_filter3(signals[k]) for k in set(STEP_SIGNALS) | set(SPIKE_SIGNALS)} | |
| for name in STEP_SIGNALS: | |
| sy, sm, nj, st = _step_stats(smoothed[name], years) | |
| out[f"step_{name}_year"] = sy | |
| out[f"step_{name}_mag"] = sm | |
| out[f"step_{name}_njumps"] = nj | |
| out[f"step_{name}_t"] = st | |
| resid = signals[name] - signals[name].mean(1, keepdims=True) | |
| out[f"step_{name}_snr"] = np.abs(sm) / (resid.std(1, ddof=1) + EPS) | |
| # ---- transient bare-soil spike: a building site is bare for 1-3 years -- | |
| for name in SPIKE_SIGNALS: | |
| M = smoothed[name] | |
| med = np.median(M, axis=1) | |
| mad = np.median(np.abs(M - med[:, None]), axis=1) + EPS | |
| k = np.argmax(M, axis=1) | |
| out[f"spike_{name}_year"] = years[k].astype(np.float32) | |
| out[f"spike_{name}_z"] = (M[np.arange(len(M)), k] - med) / mad | |
| # does it come back down? a permanent rise is a new roof, a spike that | |
| # returns to baseline is a construction site | |
| after = np.clip(k + 3, 0, M.shape[1] - 1) | |
| out[f"spike_{name}_returns"] = ((M[np.arange(len(M)), after] - med) / mad) | |
| # ---- post-construction greening: street trees take decades to mature --- | |
| ndvi = signals["NDVI"] | |
| sy_b = out["step_brightness_year"] | |
| slope_after = np.zeros(len(ndvi), dtype=np.float32) | |
| years_since = np.zeros(len(ndvi), dtype=np.float32) | |
| for i in range(len(ndvi)): | |
| k = int(np.searchsorted(years, sy_b[i])) | |
| seg = ndvi[i, k:] | |
| years_since[i] = float(years[-1] - sy_b[i]) | |
| if len(seg) >= 5: | |
| t = np.arange(len(seg), dtype=np.float32) | |
| t -= t.mean() | |
| slope_after[i] = float((seg * t).sum() / (t * t).sum() * 10.0) | |
| out["post_step_NDVI_slope_decade"] = slope_after | |
| out["years_since_step"] = years_since | |
| # ---- what was there before the change ---------------------------------- | |
| # 0 vegetated, 1 bare, 2 already built, 3 water. For a pixel that changed | |
| # after 2004 this separates greenfield expansion from brownfield infill and | |
| # from land reclamation, which have completely different trajectories but | |
| # land in the same class. | |
| pre_ndvi = out["sr_NDVI_early_mean"] | |
| pre_mndwi = out["sr_MNDWI_early_mean"] | |
| pre_ndbi = out["sr_NDBI_early_mean"] | |
| pre = np.full(len(pre_ndvi), 2.0, dtype=np.float32) | |
| pre[pre_ndvi > 0.30] = 0.0 | |
| pre[(pre_ndvi <= 0.30) & (pre_ndbi < 0.0) & (out["sr_BSI_early_mean"] > 0.05)] = 1.0 | |
| pre[pre_mndwi > 0.10] = 3.0 | |
| out["pre_state"] = pre | |
| if sat is not None: | |
| out.update(sensor_features(pix, years, observed, sat)) | |
| out["n_observed_years"] = observed.sum(1).astype(np.float32) | |
| out["first_observed_year"] = years[np.argmax(observed, axis=1)].astype(np.float32) | |
| last = observed.shape[1] - 1 - np.argmax(observed[:, ::-1], axis=1) | |
| out["last_observed_year"] = years[last].astype(np.float32) | |
| return pd.concat([pix, pd.DataFrame(out, index=pix.index)], axis=1) | |
| PRE_STATE_WORDS = {0: "vegetated - farmland, scrub or garden", | |
| 1: "bare soil or a cleared site", | |
| 2: "already built on or paved", | |
| 3: "open water (later reclaimed)"} | |
| def add_city_geometry(df: pd.DataFrame) -> pd.DataFrame: | |
| """ | |
| Two purely geometric, label-free features. | |
| `radial_frac`: distance from the city centroid, divided by the city's own | |
| 95th percentile, so it is comparable between cities. European cities share a | |
| morphological gradient - oldest and densest in the historic core - and this | |
| is the only transferable handle on the class 1 / class 2 split, whose | |
| boundary year (1960 vs 1945) has no spectral correlate at all. | |
| `dist_to_water_px`: distance to the nearest pixel that looks like open | |
| water, which is what separates the Amsterdam canal belt from everything | |
| else. | |
| """ | |
| from scipy.spatial import cKDTree | |
| out = df.copy() | |
| xy = out[PID].to_numpy(float) | |
| centre = np.median(xy, axis=0) | |
| r = np.linalg.norm(xy - centre, axis=1) | |
| out["radial_px"] = r | |
| out["radial_frac"] = r / (np.percentile(r, 95) + EPS) | |
| far = float(np.percentile(r, 99) * 2.0) # sentinel: "no water anywhere near" | |
| water = out["sr_MNDWI_max"].to_numpy(float) > 0.0 if "sr_MNDWI_max" in out else None | |
| if water is not None and water.sum() > 0: | |
| tree = cKDTree(xy[water]) | |
| d, _ = tree.query(xy, k=1) | |
| out["dist_to_water_px"] = np.minimum(d, far) | |
| else: | |
| out["dist_to_water_px"] = far | |
| return out | |
| # --------------------------------------------------------------------------- # | |
| # Fallback: start from the organisers' preprocessed_data.pkl | |
| # --------------------------------------------------------------------------- # | |
| def from_official_pkl(path): | |
| """ | |
| Load the organisers' `preprocessed_data.pkl` into the same shape | |
| `build_city` produces, so the GBDT notebook can run without the 3 GB of raw | |
| parquet. | |
| What you get: the official 60 features, the labels, and the pixel | |
| coordinates - enough for every classical model, the spatial validation, the | |
| shift harness and the full few-shot protocol. | |
| What you do NOT get: the extended features (true surface reflectance, | |
| construction-event detection, per-decade means, tasseled cap, | |
| chromaticity, building coverage) and therefore not the physical values the | |
| verbaliser's word bands need. The pkl's X is already z-scored with Madrid's | |
| scaler, so the numbers are unitless. Within-city RANKS still work, which is | |
| the main transfer mechanism - so a ranks-only prompt is possible from this | |
| file alone, just a thinner one. | |
| Returns (madrid_df, amsterdam_df). | |
| """ | |
| import pickle | |
| with open(path, "rb") as fh: | |
| d = pickle.load(fh) | |
| names = list(d["feature_names"]) | |
| out = [] | |
| for city in ("madrid", "amsterdam"): | |
| X = np.asarray(d[f"X_{city}"], dtype=np.float32) | |
| y = np.asarray(d[f"y_{city}"], dtype=int) | |
| ids = np.asarray(d[f"pixel_ids_{city}"]) | |
| df = pd.DataFrame(X, columns=names) | |
| df.insert(0, "px_key", ids[:, 0]) | |
| df.insert(1, "py_key", ids[:, 1]) | |
| df["age_class"] = y | |
| df["city"] = city.capitalize() | |
| out.append(df) | |
| print(f"loaded {path}") | |
| for name, df in zip(("Madrid", "Amsterdam"), out): | |
| print(f" {name}: {len(df):,} pixels x {len(names)} official features " | |
| f"| classes {df.age_class.value_counts().sort_index().to_dict()}") | |
| print(" note: features are Madrid-scaled z-scores, not physical units; " | |
| "the extended feature set is absent") | |
| # Guard against loading the wrong file - a truncated download, a synthetic | |
| # test fixture, or another city's export - and silently reporting numbers | |
| # from it. The organisers' pixel counts are published in the competitors' | |
| # guide, so they are a free integrity check. | |
| import os | |
| if os.environ.get("ALLOW_SYNTHETIC_PKL") == "1": | |
| print(" WARNING: integrity check disabled by " | |
| "ALLOW_SYNTHETIC_PKL - numbers are NOT real") | |
| return tuple(out) | |
| expected = {"Madrid": 76_263, "Amsterdam": 25_992} | |
| problems = [] | |
| for name, df in zip(("Madrid", "Amsterdam"), out): | |
| if len(df) != expected[name]: | |
| problems.append(f"{name} has {len(df):,} pixels, expected " | |
| f"{expected[name]:,}") | |
| if len(names) != 60: | |
| problems.append(f"{len(names)} feature names, expected 60") | |
| if problems: | |
| raise ValueError( | |
| "This does not look like the organisers' preprocessed_data.pkl:\n " | |
| + "\n ".join(problems) | |
| + f"\nFile: {path}\nIf you meant to use a test fixture, load it " | |
| "explicitly rather than through this function.") | |
| print(" integrity check passed (pixel counts match the published figures)") | |
| return tuple(out) | |
| # --------------------------------------------------------------------------- # | |
| # Driver | |
| # --------------------------------------------------------------------------- # | |
| def build_city(parquet_path, city_hint: str | None = None, verbose: bool = True, | |
| agg: str = "median", geometry: bool = True) -> pd.DataFrame: | |
| if verbose: | |
| print(f"reading {parquet_path}") | |
| df = pd.read_parquet(parquet_path) | |
| if "city" not in df.columns and city_hint: | |
| df["city"] = city_hint | |
| if verbose: | |
| print(f" {len(df):,} rows x {df.shape[1]} cols | " | |
| f"years {df['year'].min()}-{df['year'].max()}") | |
| df = assign_age_class(df) | |
| flat = make_flat_view(df, agg=agg) | |
| del df | |
| indicators = period_coverage(flat) | |
| meta_cols = [c for c in ["city", "coverage", "weighted_mean_year", "age_class"] | |
| if c in flat.columns] | |
| meta = (flat.sort_values(PID + ["year"]) | |
| .groupby(PID, as_index=False)[meta_cols].first()) | |
| pix, years, mats, observed, sat = to_year_matrices(flat) | |
| del flat | |
| off = build_official_60(pix, years, mats, indicators) | |
| ext = build_extended(pix, years, mats, observed, sat) | |
| feats = off.merge(ext, on=PID, how="left").merge(meta, on=PID, how="left") | |
| feats = feats[feats["age_class"].notna()].reset_index(drop=True) | |
| feats["age_class"] = feats["age_class"].astype(int) | |
| if geometry: | |
| feats = add_city_geometry(feats) | |
| if verbose: | |
| n_ext = ext.shape[1] - len(PID) | |
| print(f" -> {len(feats):,} labelled pixels | 60 official + {n_ext} extended features") | |
| return feats | |