..
This commit is contained in:
parent
ab688243d7
commit
463bd4c647
54 changed files with 13239 additions and 625 deletions
|
|
@ -4,7 +4,7 @@
|
|||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Postcode Boundary Quality — Bank Station (1km radius)\n",
|
||||
"# Postcode Boundary Quality: Bank Station (1km radius)\n",
|
||||
"\n",
|
||||
"Compares postcode boundaries **before** and **after** greenspace/water subtraction."
|
||||
]
|
||||
|
|
|
|||
|
|
@ -390,14 +390,14 @@
|
|||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"PROPERTY_TYPE — 5 distinct values:\n",
|
||||
"PROPERTY_TYPE: 5 distinct values:\n",
|
||||
" House 17,437,884\n",
|
||||
" Flat 8,236,696\n",
|
||||
" Bungalow 2,448,109\n",
|
||||
" Maisonette 710,695\n",
|
||||
" Park home 14,577\n",
|
||||
"\n",
|
||||
"BUILT_FORM — 9 distinct values:\n",
|
||||
"BUILT_FORM: 9 distinct values:\n",
|
||||
" Semi-Detached 8,777,318\n",
|
||||
" Mid-Terrace 7,972,697\n",
|
||||
" Detached 6,428,144\n",
|
||||
|
|
@ -414,7 +414,7 @@
|
|||
"source": [
|
||||
"for col_name in [\"PROPERTY_TYPE\", \"BUILT_FORM\"]:\n",
|
||||
" counts = scan().group_by(col_name).len().sort(\"len\", descending=True).collect()\n",
|
||||
" print(f\"{col_name} — {len(counts)} distinct values:\")\n",
|
||||
" print(f\"{col_name}: {len(counts)} distinct values:\")\n",
|
||||
" for row in counts.iter_rows(named=True):\n",
|
||||
" print(f\" {row[col_name]!s:30s} {row['len']:>12,}\")\n",
|
||||
" print()"
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@
|
|||
"id": "21f27a93",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Online Buy Listings — data quality & cleanup\n",
|
||||
"# Online Buy Listings: data quality & cleanup\n",
|
||||
"\n",
|
||||
"Source: `finder/data/online_listings_buy.parquet` — ~112k UK *for-sale* property listings\n",
|
||||
"Source: `finder/data/online_listings_buy.parquet`, ~112k UK *for-sale* property listings\n",
|
||||
"(Greater London) scraped from **Rightmove**, **OnTheMarket** and **Zoopla** and merged by the\n",
|
||||
"`finder` pipeline (`transform.py` / `onthemarket.py` / `zoopla.py` → `storage.py`).\n",
|
||||
"\n",
|
||||
|
|
@ -453,7 +453,7 @@
|
|||
"id": "7ea9293c",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"### 3.1 · 🔴 Mislabeled column — `Number of bedrooms & living rooms` is actually `Bedrooms + Bathrooms`\n",
|
||||
"### 3.1 · 🔴 Mislabeled column: `Number of bedrooms & living rooms` is actually `Bedrooms + Bathrooms`\n",
|
||||
"\n",
|
||||
"The column name promises *living rooms / receptions*, but `storage.py` recomputes it as\n",
|
||||
"`Bedrooms + Bathrooms` for **every** row of **every** portal (Zoopla's `+ receptions` line is\n",
|
||||
|
|
@ -529,7 +529,7 @@
|
|||
"### 3.2 · 🔴 Price integrity\n",
|
||||
"\n",
|
||||
"- **Shared-ownership / part-buy** listings store the *share* price as `Asking price`, not full value\n",
|
||||
" (median ~£146k vs ~£550k) — corrupts any price / £-per-sqm aggregate.\n",
|
||||
" (median ~£146k vs ~£550k). Corrupts any price / £-per-sqm aggregate.\n",
|
||||
"- **38% of rows carry a non-firm qualifier** (Guide / Offers Over / OIEO / From / Shared); `Asking price`\n",
|
||||
" is stored identically regardless.\n",
|
||||
"- A `<£10k` tail of auction land/garage lots + two `£1` placeholders; 364 nulls (price ≤ 0 → null)."
|
||||
|
|
@ -657,7 +657,7 @@
|
|||
"\n",
|
||||
"- ~49% of `Total floor area (sqm)` is null (OnTheMarket ~87%).\n",
|
||||
"- **Impossible tiny areas**: rows under 20 m² with ≥2 bedrooms (single-room dimensions parsed as total).\n",
|
||||
"- **Suspiciously large areas** (>400 m²) — likely sq ft never converted to m².\n",
|
||||
"- **Suspiciously large areas** (>400 m²): likely sq ft never converted to m².\n",
|
||||
"- `Asking price per sqm` is mechanically correct but faithfully amplifies bad areas (max £410,959/m²)."
|
||||
]
|
||||
},
|
||||
|
|
@ -755,7 +755,7 @@
|
|||
}
|
||||
],
|
||||
"source": [
|
||||
"# Floor area vs bedrooms — red points are physically impossible (area < beds * 8 m²)\n",
|
||||
"# Floor area vs bedrooms: red points are physically impossible (area < beds * 8 m²)\n",
|
||||
"s = raw.filter(fa.is_not_null() & (fa < 160)).select(\"Bedrooms\", \"Total floor area (sqm)\")\n",
|
||||
"rng = np.random.default_rng(0)\n",
|
||||
"xb = s[\"Bedrooms\"].to_numpy().astype(float)\n",
|
||||
|
|
@ -946,7 +946,7 @@
|
|||
}
|
||||
],
|
||||
"source": [
|
||||
"# Listings by provider (downsampled) — shows London footprint + centroid clusters\n",
|
||||
"# Listings by provider (downsampled): shows London footprint + centroid clusters\n",
|
||||
"g = raw.sample(min(25_000, raw.height), seed=0).select(\"lat\", \"lon\", \"provider\")\n",
|
||||
"colors = {\"Rightmove\": \"#2563eb\", \"OnTheMarket\": \"#16a34a\", \"Zoopla\": \"#dc2626\"}\n",
|
||||
"fig, ax = plt.subplots(figsize=(8, 7))\n",
|
||||
|
|
@ -971,7 +971,7 @@
|
|||
"\n",
|
||||
"No cross-source identity resolution: the same physical property appears across portals, and is\n",
|
||||
"re-listed multiple times within a portal (especially Zoopla). `UPRN` (Zoopla-only, 1.8% coverage)\n",
|
||||
"is not 1:1 — 405 UPRNs repeat up to 7×, breaking the intended exact EPC join."
|
||||
"is not 1:1. 405 UPRNs repeat up to 7×, breaking the intended exact EPC join."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
@ -1024,7 +1024,7 @@
|
|||
"- `Listing status` is a constant `\"For sale\"` (dead column).\n",
|
||||
"- `Property sub-type` has 79 values with portal-spelling variants (`Apartment`↔`Flat`, `X House`↔`X`, …).\n",
|
||||
"- `Price qualifier` has case-only duplicate pairs (Rightmove TitleCase vs OTM sentence-case).\n",
|
||||
"- Missing values are encoded inconsistently — empty-string in some columns, `null` in others."
|
||||
"- Missing values are encoded inconsistently: empty-string in some columns, `null` in others."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
@ -1155,7 +1155,7 @@
|
|||
"### 3.8 · 🔴/🟡 Listing date\n",
|
||||
"\n",
|
||||
"`Listing date` is **null for 100% of OnTheMarket & Zoopla** (Rightmove-only `firstVisibleDate`), so any\n",
|
||||
"recency analysis is biased to 82% of the data — and it reaches back to **2011** in a live for-sale set."
|
||||
"recency analysis is biased to 82% of the data, and it reaches back to **2011** in a live for-sale set."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
@ -1293,7 +1293,7 @@
|
|||
"id": "2491e545",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"**Step A** — drop dead/redundant columns, normalise empty-string → null, canonicalise `Price qualifier`, derive `price_basis` + `is_shared_ownership`."
|
||||
"**Step A**: drop dead/redundant columns, normalise empty-string → null, canonicalise `Price qualifier`, derive `price_basis` + `is_shared_ownership`."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
@ -1392,7 +1392,7 @@
|
|||
"id": "58767c84",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"**Step B** — floor-area sanity (null impossibly-small, flag suspiciously-large) + recompute `Asking price per sqm` (excluding shared-ownership); null sentinel-`0` beds/baths for dwellings; canonicalise sub-types; add `is_residential`, `location_precision`; strip fake unit postcodes from outcode-only rows."
|
||||
"**Step B**: floor-area sanity (null impossibly-small, flag suspiciously-large) + recompute `Asking price per sqm` (excluding shared-ownership); null sentinel-`0` beds/baths for dwellings; canonicalise sub-types; add `is_residential`, `location_precision`; strip fake unit postcodes from outcode-only rows."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
@ -1481,7 +1481,7 @@
|
|||
"id": "33e81cf5",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"**Step C** — de-duplicate to one row per physical property. Heuristic key: `(lat, lon, Asking price, Bedrooms)`, keeping the most-recently-listed row. This collapses both cross-portal and intra-portal duplicates; `raw` is retained for provenance."
|
||||
"**Step C**: de-duplicate to one row per physical property. Heuristic key: `(lat, lon, Asking price, Bedrooms)`, keeping the most-recently-listed row. This collapses both cross-portal and intra-portal duplicates; `raw` is retained for provenance."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
@ -1554,7 +1554,7 @@
|
|||
"id": "7d17dd05",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 5 · After cleanup — re-show the stats"
|
||||
"## 5 · After cleanup: re-show the stats"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
@ -1737,7 +1737,7 @@
|
|||
"id": "0a16d168",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"**Missingness after cleanup** — beds/baths now legitimately null where unknown; empty-strings gone; per-sqm follows cleaned area:"
|
||||
"**Missingness after cleanup**: beds/baths now legitimately null where unknown; empty-strings gone; per-sqm follows cleaned area:"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
@ -1924,7 +1924,7 @@
|
|||
"id": "e833c578",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"**Categoricals normalised** — sub-type variants collapsed, qualifier casing merged:"
|
||||
"**Categoricals normalised**: sub-type variants collapsed, qualifier casing merged:"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
@ -2007,7 +2007,7 @@
|
|||
"id": "94ac0585",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"**Before/after — key metrics chart:**"
|
||||
"**Before/after key metrics chart:**"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
@ -2076,7 +2076,7 @@
|
|||
"\n",
|
||||
"- **`clean` keeps every row** (issues are flagged, not dropped) so it stays comparable to `raw`; use the\n",
|
||||
" `is_residential` / `is_shared_ownership` / `floor_area_suspect_*` / `location_precision` flags to filter.\n",
|
||||
"- **Shared-ownership full prices cannot be recovered** here — those rows are flagged and excluded from\n",
|
||||
"- **Shared-ownership full prices cannot be recovered** here: those rows are flagged and excluded from\n",
|
||||
" `£/sqm`, but their `Asking price` is still a share. Likewise **suspiciously-large areas are flagged,\n",
|
||||
" not auto-converted** from sq ft (the conversion factor isn't certain per-row).\n",
|
||||
"- **De-dup is heuristic** (`lat,lon,price,beds`); it can over-collapse distinct units sharing a\n",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
"id": "46a28f40",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# School catchment model — the working\n",
|
||||
"# School catchment model: the working\n",
|
||||
"\n",
|
||||
"The postcode features **\"Good+/Outstanding primary/secondary school catchments\"** count the\n",
|
||||
"rated state schools whose modelled *admission cutoff radius* covers a postcode. This notebook\n",
|
||||
|
|
@ -17,7 +17,7 @@
|
|||
"Pupil Database. What *is* public: where every school is and how many pupils it has (GIAS), how\n",
|
||||
"many children live where (Census 2021), and the fact that most English admissions are run as\n",
|
||||
"**deferred acceptance with distance tie-breaks**. That is enough to *solve for* each school's\n",
|
||||
"cutoff distance — the \"last distance offered\" that councils publish each offer day — and those\n",
|
||||
"cutoff distance (the \"last distance offered\" that councils publish each offer day) and those\n",
|
||||
"published figures give us ground truth to calibrate against.\n",
|
||||
"\n",
|
||||
"The production code is `pipeline/transform/school_catchments.py`; the calibration harness is\n",
|
||||
|
|
@ -81,14 +81,14 @@
|
|||
"id": "e13f2bc4",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 1. Supply — schools and their phase fill targets\n",
|
||||
"## 1. Supply: schools and their phase fill targets\n",
|
||||
"\n",
|
||||
"Every open, **non-selective** state school (academies, LA-maintained, free schools) takes part.\n",
|
||||
"Grammar schools are excluded outright: their intakes are test-based and region-wide, so any\n",
|
||||
"distance-based catchment would be fabricated. Independent, special and Welsh schools don't\n",
|
||||
"admit by distance either.\n",
|
||||
"\n",
|
||||
"A school's *fill target* is `max(capacity, headcount)` — an over-full school keeps its\n",
|
||||
"A school's *fill target* is `max(capacity, headcount)`: an over-full school keeps its\n",
|
||||
"demonstrated size, an under-full one can admit up to capacity (the feature asks \"would you get\n",
|
||||
"a place?\", not \"does a pupil already live there?\"). The target is prorated over the cohort ages\n",
|
||||
"the school teaches, parsed from its age range: nursery years weigh 0.5 and sixth-form years 0.6,\n",
|
||||
|
|
@ -182,16 +182,16 @@
|
|||
"id": "2905514f",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 2. Demand — children per postcode\n",
|
||||
"## 2. Demand: children per postcode\n",
|
||||
"\n",
|
||||
"Census 2021 (TS007A) gives children by five-year band per LSOA. Bands don't align with school\n",
|
||||
"phases, so phases take fractional shares — primary (ages 4–10) = ⅕·(0–4) + (5–9) + ⅕·(10–14);\n",
|
||||
"secondary (11–15) = ⅘·(10–14) + ⅕·(15–19) — and each LSOA's total is split evenly across its\n",
|
||||
"phases, so phases take fractional shares: primary (ages 4–10) = ⅕·(0–4) + (5–9) + ⅕·(10–14);\n",
|
||||
"secondary (11–15) = ⅘·(10–14) + ⅕·(15–19), and each LSOA's total is split evenly across its\n",
|
||||
"live postcodes (LSOAs hold ~40 postcodes, small enough at catchment scale).\n",
|
||||
"\n",
|
||||
"Not all of those children compete for state places: births fell ~10% between 2016 and 2021\n",
|
||||
"(exactly the gap between the census stock and the cohorts reaching Reception by mid-decade) and\n",
|
||||
"~7% attend independent schools or are home-educated. `DEMAND_SCALE = 0.8` absorbs both — without\n",
|
||||
"~7% attend independent schools or are home-educated. `DEMAND_SCALE = 0.8` absorbs both, without\n",
|
||||
"it, modelled cutoffs run systematically tight and half the genuinely undersubscribed schools\n",
|
||||
"look full (this was the single biggest correction the ground truth forced; see §7).\n"
|
||||
]
|
||||
|
|
@ -241,18 +241,18 @@
|
|||
"id": "20d44b21",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 3. Preferences — grade bonuses and logit choice\n",
|
||||
"## 3. Preferences: grade bonuses and logit choice\n",
|
||||
"\n",
|
||||
"Families don't just pick the nearest school. Two ingredients:\n",
|
||||
"\n",
|
||||
"- **Grade bonus** — a school's *effective distance* is its real distance minus an Ofsted-grade\n",
|
||||
"- **Grade bonus**: a school's *effective distance* is its real distance minus an Ofsted-grade\n",
|
||||
" bonus (+0.6 km Outstanding, +0.3 km Good, −0.3/−0.6 km for grade 3/4). A family accepts that\n",
|
||||
" much extra travel for a better school.\n",
|
||||
"- **Logit smearing** — even so, not everyone at a postcode ranks the same school first. Each\n",
|
||||
"- **Logit smearing**: even so, not everyone at a postcode ranks the same school first. Each\n",
|
||||
" postcode's children split across the nearby feasible schools with weights\n",
|
||||
" `softmax(−effective_distance / τ)`, τ = 0.3 km. This matters more than it looks: with\n",
|
||||
" deterministic choice a popular school fills entirely from its nearest band, putting its\n",
|
||||
" marginal admitted child — and therefore its cutoff — unrealistically close (about 2× too\n",
|
||||
" marginal admitted child (and therefore its cutoff) unrealistically close (about 2× too\n",
|
||||
" tight against published cutoffs).\n",
|
||||
"\n",
|
||||
"Below: the share of applications a Good school captures against an unrated neighbour 1 km away.\n"
|
||||
|
|
@ -306,7 +306,7 @@
|
|||
"id": "04bcfbcf",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 4. The equilibrium — cutoff dynamics\n",
|
||||
"## 4. The equilibrium: cutoff dynamics\n",
|
||||
"\n",
|
||||
"English admissions run deferred acceptance with distance priority; in a continuum economy that\n",
|
||||
"is equivalent to finding **market-clearing cutoff distances** (Azevedo & Leshno 2016). The solver:\n",
|
||||
|
|
@ -314,7 +314,7 @@
|
|||
"1. start every school's cutoff at ∞;\n",
|
||||
"2. every child unit applies to its preferred school(s) among those whose cutoff still covers it;\n",
|
||||
"3. every oversubscribed school tightens its cutoff to the distance of its **marginal admitted\n",
|
||||
" child** — exactly the published \"last distance offered\";\n",
|
||||
" child**, exactly the published \"last distance offered\";\n",
|
||||
"4. repeat. Cutoffs only ever tighten, so the iteration converges to the deferred-acceptance\n",
|
||||
" outcome. Schools that never fill keep no binding cutoff; their radius falls back to the\n",
|
||||
" distance within which the local child population would cover their fill target.\n",
|
||||
|
|
@ -533,7 +533,7 @@
|
|||
"metadata": {},
|
||||
"source": [
|
||||
"The bimodal logic is visible: oversubscribed urban schools cluster well under 1 km while schools\n",
|
||||
"with spare places reach further. A concrete slice — Cambridge and its villages. Circles are the\n",
|
||||
"with spare places reach further. A concrete slice: Cambridge and its villages. Circles are the\n",
|
||||
"calibrated catchment radii of Good+ primary schools: tight in town, wide in the villages.\n"
|
||||
]
|
||||
},
|
||||
|
|
@ -600,11 +600,11 @@
|
|||
"id": "25770af8",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 6. Calibration — modelled vs published cutoffs\n",
|
||||
"## 6. Calibration: modelled vs published cutoffs\n",
|
||||
"\n",
|
||||
"Councils publish each school's **last distance offered** in their allocation reports. We scraped\n",
|
||||
"783 rows from nine authorities (Hertfordshire, Surrey, Stockport, Manchester, Bristol, Barnet,\n",
|
||||
"Redbridge, Ealing, Lambeth — `property-data/ground_truth/`), matched them to GIAS URNs, and\n",
|
||||
"Redbridge, Ealing, Lambeth: `property-data/ground_truth/`), matched them to GIAS URNs, and\n",
|
||||
"compare against the modelled radii. Faith schools are reported separately: their published\n",
|
||||
"cutoff applies *within* faith priority, which a postcode model cannot see. \"All applicants\n",
|
||||
"offered\" schools test whether the model agrees there was no binding cutoff at all.\n"
|
||||
|
|
@ -823,11 +823,11 @@
|
|||
"source": [
|
||||
"## 8. Limitations\n",
|
||||
"\n",
|
||||
"- **Faith admissions are not modelled** — whether a faith school's catchment is open to a given\n",
|
||||
"- **Faith admissions are not modelled**: whether a faith school's catchment is open to a given\n",
|
||||
" family depends on the family. Their fit is accordingly worse (the orange triangles above).\n",
|
||||
"- **Cutoffs are single-year snapshots**; real ones move with each cohort. The model is a\n",
|
||||
" steady-state estimate, not this September's number.\n",
|
||||
"- **Straight-line distance** is used throughout — it is the modal LA tie-break, but some\n",
|
||||
"- **Straight-line distance** is used throughout. It is the modal LA tie-break, but some\n",
|
||||
" authorities measure walking routes, and none of sibling priority, feeder schools or\n",
|
||||
" designated catchment polygons are visible to the model.\n",
|
||||
"- Census 2021 child counts age; `DEMAND_SCALE` should drift upward as the birth-rate dip works\n",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
"id": "db1n423kpm8",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Rightmove vs Home.co.uk — Source Overlap Analysis\n",
|
||||
"# Rightmove vs Home.co.uk: Source Overlap Analysis\n",
|
||||
"\n",
|
||||
"The property scraper collects listings from two sources: **Rightmove** and **home.co.uk**. During merging, cross-source deduplication removes home.co.uk listings that match a Rightmove listing by `(postcode, bedrooms, price)`.\n",
|
||||
"\n",
|
||||
|
|
@ -81,7 +81,7 @@
|
|||
"\n",
|
||||
"The merged parquet contains already-deduplicated data. Home.co.uk listings that matched a Rightmove listing by `(postcode, bedrooms, price)` were removed during scraping. The log reported **2,220 cross-source dedupes** for BUY.\n",
|
||||
"\n",
|
||||
"So the true home.co.uk total was 20,650 (unique) + 2,220 (deduped) = **22,870** — giving a **9.7% overlap rate** on the outcodes that were scraped."
|
||||
"So the true home.co.uk total was 20,650 (unique) + 2,220 (deduped) = **22,870**, giving a **9.7% overlap rate** on the outcodes that were scraped."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
@ -3164,7 +3164,7 @@
|
|||
"---\n",
|
||||
"## 3. Approximate Overlap via Fuzzy Matching\n",
|
||||
"\n",
|
||||
"The scraper deduped by exact `(postcode, bedrooms, price)`. We can also check for near-matches — properties at the same postcode with similar price that might be the same listing with slightly different data."
|
||||
"The scraper deduped by exact `(postcode, bedrooms, price)`. We can also check for near-matches: properties at the same postcode with similar price that might be the same listing with slightly different data."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
@ -5246,7 +5246,7 @@
|
|||
}
|
||||
],
|
||||
"source": [
|
||||
"# Property sub-type comparison — top home.co.uk sub-types\n",
|
||||
"# Property sub-type comparison: top home.co.uk sub-types\n",
|
||||
"hk_subtypes = (\n",
|
||||
" buy.filter(pl.col(\"source\") == \"Home.co.uk\")[\"Property sub-type\"]\n",
|
||||
" .value_counts()\n",
|
||||
|
|
@ -6304,7 +6304,7 @@
|
|||
"---\n",
|
||||
"## 7. What Does Home.co.uk Add?\n",
|
||||
"\n",
|
||||
"Home.co.uk listings that passed the dedup filter are genuinely unique — not on Rightmove at all (or listed with different price/beds). What do they look like?"
|
||||
"Home.co.uk listings that passed the dedup filter are genuinely unique: not on Rightmove at all (or listed with different price/beds). What do they look like?"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
@ -6409,13 +6409,13 @@
|
|||
"output_type": "stream",
|
||||
"text": [
|
||||
"\n",
|
||||
"Rightmove — days on market:\n",
|
||||
"Rightmove: days on market:\n",
|
||||
" Median: 74\n",
|
||||
" Mean: 140\n",
|
||||
" P25: 26\n",
|
||||
" P75: 189\n",
|
||||
"\n",
|
||||
"Home.co.uk — days on market:\n",
|
||||
"Home.co.uk: days on market:\n",
|
||||
" Median: 156\n",
|
||||
" Mean: 164\n",
|
||||
" P25: 50\n",
|
||||
|
|
@ -6424,7 +6424,7 @@
|
|||
}
|
||||
],
|
||||
"source": [
|
||||
"# Listing age comparison — are home.co.uk listings older/newer?\n",
|
||||
"# Listing age comparison: are home.co.uk listings older/newer?\n",
|
||||
"import datetime\n",
|
||||
"\n",
|
||||
"now = datetime.datetime(2026, 3, 11)\n",
|
||||
|
|
@ -6435,7 +6435,7 @@
|
|||
"for src in [\"Rightmove\", \"Home.co.uk\"]:\n",
|
||||
" age = with_age.filter(pl.col(\"source\") == src)[\"days_on_market\"].drop_nulls()\n",
|
||||
" if len(age) > 0:\n",
|
||||
" print(f\"\\n{src} — days on market:\")\n",
|
||||
" print(f\"\\n{src}: days on market:\")\n",
|
||||
" print(f\" Median: {age.median():.0f}\")\n",
|
||||
" print(f\" Mean: {age.mean():.0f}\")\n",
|
||||
" print(f\" P25: {age.quantile(0.25):.0f}\")\n",
|
||||
|
|
@ -7388,7 +7388,7 @@
|
|||
" Projected unique additions: ~274,584\n",
|
||||
" Projected merged dataset: ~728,899 (60.4% increase)\n",
|
||||
"\n",
|
||||
"⚠️ These are rough estimates — the covered outcodes may not be representative\n"
|
||||
"⚠️ These are rough estimates: the covered outcodes may not be representative\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
|
|
@ -7425,7 +7425,7 @@
|
|||
" f\" Projected merged dataset: ~{rm_buy + projected_unique:,} ({projected_unique / rm_buy * 100:.1f}% increase)\"\n",
|
||||
")\n",
|
||||
"print()\n",
|
||||
"print(\"⚠️ These are rough estimates — the covered outcodes may not be representative\")"
|
||||
"print(\"⚠️ These are rough estimates: the covered outcodes may not be representative\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -403120,7 +403120,7 @@
|
|||
}
|
||||
},
|
||||
"title": {
|
||||
"text": "Bank — Median transit error (R5 − TfL easy), minutes"
|
||||
"text": "Bank: Median transit error (R5 − TfL easy), minutes"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -403143,7 +403143,7 @@
|
|||
" zoom=6,\n",
|
||||
" center={\"lat\": 51.5, \"lon\": -0.1},\n",
|
||||
" opacity=0.5,\n",
|
||||
" title=\"Bank — Median transit error (R5 − TfL easy), minutes\",\n",
|
||||
" title=\"Bank: Median transit error (R5 − TfL easy), minutes\",\n",
|
||||
" hover_data={\n",
|
||||
" \"pcds\": True,\n",
|
||||
" \"travel_minutes\": True,\n",
|
||||
|
|
@ -804061,7 +804061,7 @@
|
|||
}
|
||||
},
|
||||
"title": {
|
||||
"text": "Bank — Best transit error (R5 − TfL quick), minutes"
|
||||
"text": "Bank: Best transit error (R5 − TfL quick), minutes"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -804081,7 +804081,7 @@
|
|||
" zoom=6,\n",
|
||||
" center={\"lat\": 51.5, \"lon\": -0.1},\n",
|
||||
" opacity=0.5,\n",
|
||||
" title=\"Bank — Best transit error (R5 − TfL quick), minutes\",\n",
|
||||
" title=\"Bank: Best transit error (R5 − TfL quick), minutes\",\n",
|
||||
" hover_data={\n",
|
||||
" \"pcds\": True,\n",
|
||||
" \"best_minutes\": True,\n",
|
||||
|
|
@ -1204999,7 +1204999,7 @@
|
|||
}
|
||||
},
|
||||
"title": {
|
||||
"text": "Bank — Absolute median transit error |R5 − TfL easy|, minutes"
|
||||
"text": "Bank: Absolute median transit error |R5 − TfL easy|, minutes"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1205019,7 +1205019,7 @@
|
|||
" zoom=6,\n",
|
||||
" center={\"lat\": 51.5, \"lon\": -0.1},\n",
|
||||
" opacity=0.5,\n",
|
||||
" title=\"Bank — Absolute median transit error |R5 − TfL easy|, minutes\",\n",
|
||||
" title=\"Bank: Absolute median transit error |R5 − TfL easy|, minutes\",\n",
|
||||
" hover_data={\n",
|
||||
" \"pcds\": True,\n",
|
||||
" \"travel_minutes\": True,\n",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue