Download notebooks/src/01_build_features.py from Karar-git/hack4dev-building-age-winners: direct link, hf CLI and curl.
- Browser
- Download file 11.8 kB
-
https://huggingface.co/Karar-git/hack4dev-building-age-winners/resolve/main/notebooks/src/01_build_features.py
- Command line
-
hf download hf://Karar-git/hack4dev-building-age-winners/notebooks/src/01_build_features.py
-
curl -L -o 01_build_features.py https://huggingface.co/Karar-git/hack4dev-building-age-winners/resolve/main/notebooks/src/01_build_features.py
11.8 kB
| # %% [markdown] | |
| # # 1 - Features: from 40 years of Landsat to one row per building pixel | |
| # | |
| # This notebook turns the two raw parquet files into two feature tables. | |
| # | |
| # It does **two** things at once: | |
| # | |
| # 1. **Reproduces the organisers' 60 features exactly** (Notebook 3), so every | |
| # number we report stays comparable with the published Random-Forest | |
| # baseline of 0.618 +- 0.004 macro F1 on Madrid. | |
| # 2. **Adds an extended set** on top: proper surface reflectance instead of raw | |
| # digital numbers, correct spectral indices, construction-event detection, | |
| # per-decade means, trends, and label-free neighbourhood context. | |
| # | |
| # Nothing here touches `y_amsterdam`. Amsterdam's *features* are used (for | |
| # per-city statistics), its *labels* are not. | |
| # | |
| # Runs on CPU in a few minutes - no GPU needed. Do this once, locally, and the | |
| # GPU notebooks just load the parquet files it writes. | |
| # %% | |
| import sys | |
| from pathlib import Path | |
| # Make src/ importable whether we are local, on Kaggle, or on Colab. | |
| for cand in [Path.cwd() / "src", Path.cwd().parent / "src", | |
| Path("/kaggle/working/src"), Path("/content/hack4dev/src")]: | |
| if cand.exists(): | |
| sys.path.insert(0, str(cand)) | |
| break | |
| import numpy as np | |
| import pandas as pd | |
| import matplotlib.pyplot as plt | |
| import config as C | |
| import features as F | |
| import retrieval as R | |
| pd.set_option("display.width", 200) | |
| pd.set_option("display.max_columns", 60) | |
| print("platform:", C.PLATFORM) | |
| print("looking for raw parquet under:", C.RAW_DIR) | |
| # %% [markdown] | |
| # ## 1.1 Find the raw files | |
| # | |
| # Expected: `madrid_train.parquet` (about 3.17 M rows) and | |
| # `amsterdam_data.parquet` (about 1.06 M rows). On Kaggle they will be inside | |
| # `/kaggle/input/<dataset-name>/`; the search below handles that. | |
| # %% | |
| def find_parquet(pattern: str) -> Path: | |
| roots = [C.RAW_DIR, C.DATA_DIR, C.PROJECT_ROOT, Path("/kaggle/input")] | |
| for root in roots: | |
| if not root.exists(): | |
| continue | |
| hits = sorted(root.rglob(pattern)) | |
| if hits: | |
| return hits[0] | |
| raise FileNotFoundError( | |
| f"{pattern} not found. Put the two parquet files in {C.RAW_DIR} " | |
| "(local) or attach them as a Kaggle dataset.") | |
| MADRID_PARQUET = find_parquet("madrid_train.parquet") | |
| AMS_PARQUET = find_parquet("amsterdam_data.parquet") | |
| print("Madrid :", MADRID_PARQUET) | |
| print("Amsterdam:", AMS_PARQUET) | |
| # %% [markdown] | |
| # ## 1.2 What is actually in the raw file | |
| # | |
| # One row per *(pixel, year)*. Up to three summer observations per year are | |
| # stored side by side as `Blue_1/Blue_2/Blue_3`, each with a `qa_valid_N` | |
| # cloud flag. Band values are raw Landsat Collection-2 integers: | |
| # | |
| # $$\text{surface reflectance} = \text{DN} \times 2.75\times10^{-5} - 0.2$$ | |
| # %% | |
| peek = pd.read_parquet(MADRID_PARQUET) | |
| print(f"{len(peek):,} rows x {peek.shape[1]} columns") | |
| print("\ncolumns:") | |
| print(list(peek.columns)) | |
| print("\nyears:", peek["year"].min(), "-", peek["year"].max(), | |
| f"({peek['year'].nunique()} distinct)") | |
| print("unique pixels:", f"{peek.groupby(C.PID).ngroups:,}") | |
| print("\nfirst row:") | |
| print(peek.iloc[0].to_string()) | |
| # %% | |
| # Reflectance sanity check on one band, and the QA-flag hit rate. | |
| dn = peek["Red_1"].dropna() | |
| sr = dn * C.SR_SCALE + C.SR_OFFSET | |
| print("Red_1 digital numbers :", dn.quantile([0, .01, .5, .99, 1]).round(0).to_dict()) | |
| print("Red_1 as reflectance :", sr.quantile([0, .01, .5, .99, 1]).round(3).to_dict()) | |
| for s in (1, 2, 3): | |
| col = f"qa_valid_{s}" | |
| if col in peek.columns: | |
| print(f"{col}: {peek[col].mean():.1%} valid") | |
| del peek | |
| # %% [markdown] | |
| # ## 1.3 Build both feature sets | |
| # | |
| # The heavy lifting lives in `src/features.py`. Per city it: | |
| # | |
| # * picks the first QA-valid observation per band per year, drops haze | |
| # (`Blue > 15000`); | |
| # * records `has_early_data` / `has_late_data` on the *observed* years; | |
| # * fills the annual series (linear inside, nearest at the edges); | |
| # * pivots to one `(pixels x years)` matrix per band, then computes every | |
| # statistic with numpy - a whole city in about a minute. | |
| # %% | |
| madrid = F.build_city(MADRID_PARQUET, city_hint="Madrid") | |
| # %% | |
| amsterdam = F.build_city(AMS_PARQUET, city_hint="Amsterdam") | |
| # %% [markdown] | |
| # ## 1.4 Does this match the organisers' own output? | |
| # | |
| # If `preprocessed_data.pkl` from the organisers' Notebook 3 is around, compare | |
| # the 60 official features against it. Their arrays are standardised with | |
| # Madrid's `StandardScaler`, so we compare **correlation per feature**, which is | |
| # invariant to that. Anything below 0.999 means our pipeline has drifted. | |
| # %% | |
| pkl = None | |
| for root in [C.DATA_DIR, C.PROJECT_ROOT, Path("/kaggle/input")]: | |
| if root.exists(): | |
| hits = sorted(root.rglob("preprocessed_data.pkl")) | |
| if hits: | |
| pkl = hits[0] | |
| break | |
| if pkl is None: | |
| print("preprocessed_data.pkl not found - skipping the cross-check.") | |
| else: | |
| import pickle | |
| with open(pkl, "rb") as fh: | |
| ref = pickle.load(fh) | |
| print("reference file:", pkl) | |
| ours = madrid.set_index(["px_key", "py_key"]) | |
| ref_ids = pd.MultiIndex.from_arrays( | |
| [ref["pixel_ids_madrid"][:, 0], ref["pixel_ids_madrid"][:, 1]], | |
| names=["px_key", "py_key"]) | |
| common = ref_ids.intersection(ours.index) | |
| print(f"pixels: ours {len(ours):,}, theirs {len(ref_ids):,}, " | |
| f"in common {len(common):,}") | |
| ref_df = pd.DataFrame(ref["X_madrid"], index=ref_ids, | |
| columns=ref["feature_names"]).loc[common] | |
| our_df = ours.loc[common, ref["feature_names"]] | |
| corr = {c: float(np.corrcoef(ref_df[c], our_df[c])[0, 1]) | |
| for c in ref["feature_names"] | |
| if ref_df[c].std() > 0 and our_df[c].std() > 0} | |
| bad = {k: v for k, v in corr.items() if v < 0.999} | |
| print(f"features compared: {len(corr)} | min correlation: " | |
| f"{min(corr.values()):.5f}") | |
| print("dead features in their file (zero variance):", | |
| [c for c in ref["feature_names"] if ref_df[c].std() == 0]) | |
| print("features below 0.999:", bad if bad else "none - pipeline matches") | |
| # Labels must agree exactly. | |
| y_ref = pd.Series(ref["y_madrid"], index=ref_ids).loc[common] | |
| print("label agreement:", | |
| f"{(y_ref.to_numpy() == our_df.index.map(ours['age_class']).to_numpy()).mean():.4%}") | |
| # %% [markdown] | |
| # ## 1.5 Class balance, and the domain gap | |
| # | |
| # Two facts drive every later design decision: | |
| # | |
| # * the classes are unbalanced, differently in each city; | |
| # * the same feature has a different *level* in each city, by much more than | |
| # the gap between classes - which is why a Madrid model collapses to ~0.34 on | |
| # Amsterdam. | |
| # %% | |
| for name, df in [("Madrid", madrid), ("Amsterdam", amsterdam)]: | |
| n = len(df) | |
| counts = df["age_class"].value_counts().sort_index() | |
| print(f"\n{name}: {n:,} labelled pixels") | |
| for c in C.CLASS_LABELS[name]: | |
| k = int(counts.get(c, 0)) | |
| print(f" class {c} ({C.CLASS_LABELS[name][c]:>11}): {k:6,} {k / n:6.1%}") | |
| # %% | |
| # How far apart are the two cities, feature by feature? | |
| # Expressed in Madrid standard deviations, so it is directly comparable. | |
| key = ["sr_brightness_mean", "sr_Red_mean", "sr_NIR_mean", "sr_SWIR1_mean", | |
| "sr_NDVI_mean", "sr_NDBI_mean", "sr_UI_mean", "sr_MNDWI_mean", | |
| "sr_BSI_mean", "sr_brightness_std", "sr_brightness_late_minus_early", | |
| "step_brightness_mag", "step_brightness_njumps", "coverage"] | |
| key = [k for k in key if k in madrid.columns and k in amsterdam.columns] | |
| gap = pd.DataFrame({ | |
| "madrid_mean": [madrid[k].mean() for k in key], | |
| "amsterdam_mean": [amsterdam[k].mean() for k in key], | |
| }, index=key) | |
| gap["madrid_sd"] = [madrid[k].std() for k in key] | |
| gap["gap_in_madrid_sd"] = (gap.amsterdam_mean - gap.madrid_mean) / gap.madrid_sd | |
| print(gap.round(3).sort_values("gap_in_madrid_sd").to_string()) | |
| # %% [markdown] | |
| # A feature whose `gap_in_madrid_sd` is near zero is one a Madrid-trained model | |
| # can still read in Amsterdam. A feature at +/-2 is telling the model which | |
| # *city* it is looking at, not how old the building is. | |
| # %% | |
| # Within-class spread versus between-city gap, for the top offenders. | |
| fig, axes = plt.subplots(2, 4, figsize=(18, 7)) | |
| for ax, k in zip(axes.ravel(), key[:8]): | |
| for name, df, col in [("Madrid", madrid, "tab:red"), | |
| ("Amsterdam", amsterdam, "tab:blue")]: | |
| ax.hist(df[k], bins=60, density=True, alpha=0.45, label=name, color=col) | |
| ax.set_title(k, fontsize=9) | |
| ax.tick_params(labelsize=7) | |
| axes.ravel()[0].legend(fontsize=8) | |
| plt.suptitle("The same feature in the two cities", fontweight="bold") | |
| plt.tight_layout() | |
| plt.show() | |
| # %% [markdown] | |
| # ## 1.6 Does the construction-event detector actually work? | |
| # | |
| # A genuine test of the extended features. For classes 3 and 4 the building went | |
| # up *during* the satellite record, so `step_brightness_year` should land near | |
| # `weighted_mean_year`. For classes 1 and 2 it was already standing in 1984, so | |
| # the detector should find nothing meaningful. If that pattern does not show up, | |
| # the detector is noise and should be dropped. | |
| # %% | |
| for name, df in [("Madrid", madrid), ("Amsterdam", amsterdam)]: | |
| print(f"\n{name}") | |
| for sig in ("brightness", "SWIR1", "NDVI"): | |
| sub = df[df.age_class.isin([3, 4])] | |
| err = (sub[f"step_{sig}_year"] - sub["weighted_mean_year"]).abs() | |
| r = np.corrcoef(sub[f"step_{sig}_year"], sub["weighted_mean_year"])[0, 1] | |
| print(f" step_{sig}_year vs true year (classes 3+4): r={r:+.3f} " | |
| f"median |error| = {err.median():.1f} years " | |
| f"within 5 y: {(err <= 5).mean():.1%}") | |
| print(" mean |step magnitude| by class:", | |
| df.groupby("age_class")["step_brightness_mag"].apply( | |
| lambda s: round(float(s.abs().mean()), 4)).to_dict()) | |
| # %% | |
| fig, axes = plt.subplots(1, 2, figsize=(13, 4.5)) | |
| for ax, (name, df) in zip(axes, [("Madrid", madrid), ("Amsterdam", amsterdam)]): | |
| sub = df[df.age_class.isin([3, 4])].sample(min(4000, len(df)), random_state=0) | |
| ax.scatter(sub["weighted_mean_year"], sub["step_brightness_year"], s=3, | |
| alpha=0.25) | |
| lims = [1984, 2024] | |
| ax.plot(lims, lims, "r--", lw=1) | |
| ax.set(xlabel="true construction year", ylabel="detected brightness step year", | |
| title=f"{name}: classes 3 and 4", xlim=lims, ylim=lims) | |
| plt.tight_layout() | |
| plt.show() | |
| # %% [markdown] | |
| # ## 1.7 Neighbourhood context - free information, no labels | |
| # | |
| # Buildings go up a street at a time, so a pixel's neighbours carry real signal | |
| # about its era. These features average a few key quantities over the 8 nearest | |
| # pixels (within 10 pixel units = 300 m) and also store the *difference* between | |
| # the pixel and its surroundings. No labels are involved, so this is available | |
| # in Amsterdam at n = 0. | |
| # %% | |
| madrid = R.add_neighbourhood_features(madrid) | |
| amsterdam = R.add_neighbourhood_features(amsterdam) | |
| nbr_cols = [c for c in madrid.columns if c.startswith("nbr_")] | |
| print(f"added {len(nbr_cols)} neighbourhood features") | |
| print(madrid[["age_class"] + nbr_cols[:6]].groupby("age_class").mean().round(4).to_string()) | |
| # %% [markdown] | |
| # ## 1.8 Save | |
| # | |
| # One parquet per city. Every later notebook starts from these. | |
| # %% | |
| C.PROC_DIR.mkdir(parents=True, exist_ok=True) | |
| madrid.to_parquet(C.PROC_DIR / "madrid_features.parquet", index=False) | |
| amsterdam.to_parquet(C.PROC_DIR / "amsterdam_features.parquet", index=False) | |
| for n, df in [("madrid", madrid), ("amsterdam", amsterdam)]: | |
| p = C.PROC_DIR / f"{n}_features.parquet" | |
| print(f"{p} {p.stat().st_size / 1e6:.1f} MB {df.shape}") | |
| print("\nfeature groups:", | |
| f"{len(C.FEATURE_COLS_60)} official,", | |
| f"{sum(1 for c in madrid.columns if c.startswith('sr_'))} reflectance-based,", | |
| f"{sum(1 for c in madrid.columns if c.startswith('step_'))} change-event,", | |
| f"{len(nbr_cols)} neighbourhood") | |