Download scripts/reextract_multipanel.py from build-small-hackathon/pay-equity-for-eu: direct link, hf CLI and curl.
- Browser
- Download file 16.2 kB
-
https://huggingface.co/spaces/build-small-hackathon/pay-equity-for-eu/resolve/6ded0ab68d5999e19fe1036139f90477c32e261b/scripts/reextract_multipanel.py
- Command line
-
hf download hf://spaces/build-small-hackathon/pay-equity-for-eu@6ded0ab68d5999e19fe1036139f90477c32e261b/scripts/reextract_multipanel.py
-
curl -L -o reextract_multipanel.py https://huggingface.co/spaces/build-small-hackathon/pay-equity-for-eu/resolve/6ded0ab68d5999e19fe1036139f90477c32e261b/scripts/reextract_multipanel.py
16.2 kB
| """Re-extract the multi-panel IDA tables whose second axis was flattened away. | |
| The original ``build_records_ida`` keyed ``measure[stat]`` by stat name, so for | |
| multi-panel tables (region t11, gender×leadership t17, management-span t14, | |
| public position t27) each panel overwrote the previous — only the last panel's | |
| numbers survived and the panel identity was lost. This script adds the lattice | |
| analogue of the Djøf ``split_panels`` logic: it finds the repeated stat band, | |
| splits columns into panels, reads each panel's segment title from the header | |
| rows above, and emits one ``IdaSalaryRecord`` per (row × panel) with the typed | |
| dimension recovered. | |
| Deterministic pdfplumber positional parsing (no OCR) — the IDA PDF is | |
| digital-born; this keeps the 0-digit-error guarantee. Output: | |
| ``data/processed/lonstatistik/ida_reextracted.jsonl`` (picked up by | |
| ``build_ida_records.py``). | |
| Usage:: uv run python scripts/reextract_multipanel.py | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import re | |
| import sys | |
| from pathlib import Path | |
| import pdfplumber | |
| REPO = Path(__file__).resolve().parents[1] | |
| sys.path.insert(0, str(REPO)) | |
| sys.path.insert(0, str(REPO / "scripts")) | |
| from extract_engine import ( # noqa: E402 | |
| cluster_rows, get_rulings, is_number, norm, parse_number, xcenter, | |
| ) | |
| from src.backend.indexing.ida_transform import ( # noqa: E402 | |
| POSITION_RANK, SPAN_BOUNDS, reliability_tier, | |
| ) | |
| from src.backend.schemas import IdaSalaryRecord, Measure # noqa: E402 | |
| IDA_PDF = REPO / "data" / "raw" / "ida-loenstatistik-2025.pdf" | |
| OUT = REPO / "data" / "processed" / "lonstatistik" / "ida_reextracted.jsonl" | |
| SOURCE_DOC = "IDA Lønstatistik 2025" | |
| def _stat_of(text: str) -> str | None: | |
| """Map a stat-header token to a measure key (these tables carry count+mean).""" | |
| t = norm(text).replace(" ", "") | |
| if "antal" in t: | |
| return "count" | |
| if "bruttolon" in t or "gennemsnit" in t or "brutolon" in t: | |
| return "mean" | |
| return None | |
| def extract_panels(pg) -> dict | None: | |
| """Geometry pass (ruling-based): stat band → panels → titles → per-(row,panel) values. | |
| Uses the vertical rulings to define exact column boundaries (robust to | |
| missing cells / dashes, which otherwise shift nearest-center assignment). | |
| Only columns whose header carries a stat token (Antal/Bruttoløn/Gennemsnit) | |
| are kept, which also drops spurious narrow rulings. A new panel begins at | |
| each 'count'. Returns ``{panel_titles, rows:[{label, panels:[{stat:val}]}]}``. | |
| """ | |
| bounds = get_rulings(pg) | |
| if len(bounds) < 4: | |
| return None | |
| ncol = len(bounds) - 1 | |
| def colof(cx: float) -> int | None: | |
| for i in range(ncol): | |
| if bounds[i] - 1 <= cx <= bounds[i + 1] + 1: | |
| return i | |
| return None | |
| words = pg.extract_words(keep_blank_chars=False) | |
| rows = cluster_rows(words) | |
| # 1) stat header row = the row with the most count/mean tokens. | |
| best_i, best_n = None, 0 | |
| for i, r in enumerate(rows): | |
| n = sum(1 for w in r if _stat_of(w["text"])) | |
| if n > best_n: | |
| best_i, best_n = i, n | |
| if best_i is None or best_n < 4: | |
| return None | |
| # 2) classify each column's stat from the header row tokens. | |
| col_stat: dict[int, str] = {} | |
| for w in rows[best_i]: | |
| s = _stat_of(w["text"]) | |
| ci = colof(xcenter(w)) | |
| if s and ci is not None and ci > 0: # col 0 is the row label | |
| col_stat[ci] = s | |
| stat_cols = sorted(col_stat) # column indices carrying a statistic | |
| if len(stat_cols) < 2: | |
| return None | |
| # 3) split stat columns into panels — a new panel begins at each 'count'. | |
| panels: list[list[int]] = [] | |
| for ci in stat_cols: | |
| if col_stat[ci] == "count" or not panels: | |
| panels.append([ci]) | |
| else: | |
| panels[-1].append(ci) | |
| # panel x-range from the ruling bounds of its first/last columns. | |
| p_lo = [bounds[p[0]] for p in panels] | |
| p_hi = [bounds[p[-1] + 1] for p in panels] | |
| first_stat_x = bounds[stat_cols[0]] | |
| # 4) panel titles: words above the stat row, bucketed by panel x-range. | |
| title_frags: list[list[str]] = [[] for _ in panels] | |
| for ri in range(best_i): | |
| for w in rows[ri]: | |
| cx = xcenter(w) | |
| if cx < first_stat_x - 4 or is_number(w["text"]): | |
| continue | |
| for pi in range(len(panels)): | |
| if p_lo[pi] - 4 <= cx <= p_hi[pi] + 4: | |
| title_frags[pi].append(w["text"]) | |
| break | |
| panel_titles = [re.sub(r"\s+", " ", " ".join(f)).strip() for f in title_frags] | |
| # 5) data rows: label in col 0; each numeric cell placed by its column. | |
| out_rows = [] | |
| for ri in range(best_i + 1, len(rows)): | |
| r = rows[ri] | |
| nums = [w for w in r if is_number(w["text"])] | |
| if len(nums) < 2: | |
| continue | |
| # Column 0 is the label column by ruling, so keep all of it — including | |
| # numeric-only labels like '1996' (single-year cohort) or '0' (span band), | |
| # which a numeric filter would wrongly drop. | |
| label = re.sub(r"\s+", " ", " ".join( | |
| w["text"] for w in sorted(r, key=lambda w: w["x0"]) | |
| if colof(xcenter(w)) == 0)).strip() | |
| if not label: | |
| continue | |
| cellvals: dict[int, float] = {} | |
| for w in nums: | |
| ci = colof(xcenter(w)) | |
| if ci is None: | |
| continue | |
| v, _pct, missing = parse_number(w["text"]) | |
| if not missing: | |
| cellvals[ci] = v | |
| panel_measures = [ | |
| {col_stat[ci]: cellvals[ci] for ci in p if ci in cellvals} | |
| for p in panels | |
| ] | |
| out_rows.append({"label": label, "panels": panel_measures}) | |
| return {"panel_titles": panel_titles, "rows": out_rows} | |
| # --- cohort / span row-label parsing --------------------------------------- | |
| def parse_cohort(label: str) -> tuple[int | None, int | None] | None: | |
| """Cohort label → (start, end) with the 'Før YYYY' half-open convention.""" | |
| low = norm(label) | |
| if low in ("alle", "ialt", "ialt"): | |
| return (None, None) | |
| m = re.search(r"(?:for)\s*(\d{4})", low) # 'før' folds to 'for' | |
| if m: | |
| return (None, int(m.group(1)) - 1) | |
| m = re.search(r"(\d{4})(?:[-–](\d{2,4}))?", low) | |
| if not m: | |
| return None | |
| y0 = int(m.group(1)) | |
| y1 = m.group(2) | |
| if y1: | |
| y1 = int(y1) if len(y1) == 4 else int(str(y0)[:2] + y1) | |
| else: | |
| y1 = y0 | |
| return (y0, y1) | |
| def parse_span(label: str) -> str | None: | |
| """Management-span row label → management_span_band enum value.""" | |
| t = label.strip().replace("–", "-").replace(" ", "") | |
| if t == "0": | |
| return "none" | |
| if t in SPAN_BOUNDS: | |
| return t | |
| m = re.match(r"^(\d+)\+$", t) | |
| if m and f"{m.group(1)}+" in SPAN_BOUNDS: | |
| return f"{m.group(1)}+" | |
| return None | |
| # --- per-table panel-title → typed dimensions ------------------------------ | |
| _REGION = { | |
| "hovedstaden": "hovedstaden", "sjælland": "sjaelland", "sjaelland": "sjaelland", | |
| "syddanmark": "syddanmark", "midtjylland": "midtjylland", "nordjylland": "nordjylland", | |
| } | |
| _PUBLIC_POSITION = [ # (keyword in title, position_level) | |
| ("specialkonsulent", "specialist"), | |
| ("chefkonsulent", "chef_seniorkonsulent"), | |
| ("menig", "ingenior_akademiker"), | |
| ("almindelig", "ingenior_akademiker"), | |
| ("chef", "afdelingschef"), | |
| ] | |
| _PRIVATE_POSITION = [ | |
| ("topchef", "topchef"), ("funktionsdirekt", "topchef"), | |
| ("afdelingschef", "afdelingschef"), ("projektleder", "projektleder"), | |
| ] | |
| def _is_alle(title: str) -> bool: | |
| return norm(title).replace("*", "").strip() in ("alle", "alleledere", "alle*") | |
| def _base(sector: str, page: int, rec_type: str = "salary_observation") -> dict: | |
| return dict( | |
| id="", source_doc=SOURCE_DOC, source_page=page, sector=sector, | |
| record_type=rec_type, currency="DKK", union="IDA", | |
| ) | |
| def build_region(rt: dict, page: int) -> list[IdaSalaryRecord]: | |
| """Tabel 11: private engineers by region × cohort (sector FIX: private).""" | |
| out = [] | |
| titles = rt["panel_titles"] | |
| for row in rt["rows"]: | |
| coh = parse_cohort(row["label"]) | |
| if coh is None: | |
| continue | |
| for pi, m in enumerate(row["panels"]): | |
| if "mean" not in m: | |
| continue | |
| region = _REGION.get(norm(titles[pi]).strip()) | |
| if region is None: # 'Alle' aggregate panel → skip (redundant) | |
| continue | |
| n = m.get("count") | |
| out.append(IdaSalaryRecord( | |
| **_base("private", page), table_id=11, table_title="REGION", | |
| pay_concept="gross_monthly", data_period_month="2025-09", | |
| region=region, row_dimension="region × kandidatår", | |
| row_label=row["label"], segment_label=titles[pi], | |
| graduation_year_start=coh[0], graduation_year_end=coh[1], | |
| experience_years_min=2025 - coh[1] if coh[1] else None, | |
| experience_years_max=2025 - coh[0] if coh[0] else None, | |
| dimension_keys=["region"] + (["graduation_year"] if coh != (None, None) else []), | |
| specificity=1 + (coh != (None, None)), | |
| sample_size=n, reliability_tier=reliability_tier(n), | |
| measure=Measure(count=n, mean=m.get("mean")), rag_text="", | |
| )) | |
| return out | |
| def build_gender(rt: dict, page: int) -> list[IdaSalaryRecord]: | |
| """Tabel 17: gender × leadership × cohort (the equal-pay comparison).""" | |
| out = [] | |
| titles = rt["panel_titles"] | |
| # 6 panels = 3 leadership groups × (Kvinde, Mand), in order. | |
| group_by_panel = {0: ("leader", "yes"), 1: ("leader", "yes"), | |
| 2: ("leader", "no"), 3: ("leader", "no"), | |
| 4: ("non_leader", "no"), 5: ("non_leader", "no")} | |
| for row in rt["rows"]: | |
| coh = parse_cohort(row["label"]) | |
| if coh is None: | |
| continue | |
| for pi, m in enumerate(row["panels"]): | |
| if "mean" not in m or pi not in group_by_panel: | |
| continue | |
| tnorm = norm(titles[pi]) | |
| gender = "female" if "kvinde" in tnorm else ("male" if "mand" in tnorm else None) | |
| if gender is None: | |
| continue | |
| is_leader, manages = group_by_panel[pi] | |
| n = m.get("count") | |
| dims = ["gender", "is_leader", "manages_people"] | |
| if coh != (None, None): | |
| dims.append("graduation_year") | |
| out.append(IdaSalaryRecord( | |
| **_base("private", page), table_id=17, | |
| table_title="MÆND OG KVINDER, ledelse med/uden personaleansvar", | |
| pay_concept="gross_monthly", data_period_month="2025-09", | |
| gender=gender, is_leader=is_leader, manages_people=manages, | |
| row_dimension="køn × ledelse × kandidatår", row_label=row["label"], | |
| segment_label=titles[pi], | |
| graduation_year_start=coh[0], graduation_year_end=coh[1], | |
| experience_years_min=2025 - coh[1] if coh[1] else None, | |
| experience_years_max=2025 - coh[0] if coh[0] else None, | |
| dimension_keys=dims, specificity=len(dims), | |
| sample_size=n, reliability_tier=reliability_tier(n), | |
| measure=Measure(count=n, mean=m.get("mean")), rag_text="", | |
| )) | |
| return out | |
| def build_public_position(rt: dict, page: int) -> list[IdaSalaryRecord]: | |
| """Tabel 27: public-state position level × cohort.""" | |
| out = [] | |
| titles = rt["panel_titles"] | |
| for row in rt["rows"]: | |
| coh = parse_cohort(row["label"]) | |
| if coh is None: | |
| continue | |
| for pi, m in enumerate(row["panels"]): | |
| if "mean" not in m or _is_alle(titles[pi]): | |
| continue | |
| tnorm = norm(titles[pi]) | |
| pos = next((p for kw, p in _PUBLIC_POSITION if kw in tnorm), None) | |
| if pos is None: | |
| continue | |
| manages = "yes" if "personaleansvar" in tnorm else "all" | |
| n = m.get("count") | |
| dims = ["position_level"] + (["graduation_year"] if coh != (None, None) else []) | |
| out.append(IdaSalaryRecord( | |
| **_base("public_state", page), table_id=27, | |
| table_title="STATEN: STILLINGSNIVEAU", | |
| pay_concept="gross_monthly", data_period_month="2025-05", | |
| position_level=pos, position_level_rank=POSITION_RANK.get(pos), | |
| manages_people=manages, | |
| row_dimension="stillingsniveau × kandidatår", row_label=row["label"], | |
| segment_label=titles[pi], | |
| graduation_year_start=coh[0], graduation_year_end=coh[1], | |
| experience_years_min=2025 - coh[1] if coh[1] else None, | |
| experience_years_max=2025 - coh[0] if coh[0] else None, | |
| dimension_keys=dims, specificity=len(dims), | |
| sample_size=n, reliability_tier=reliability_tier(n), | |
| measure=Measure(count=n, mean=m.get("mean")), rag_text="", | |
| )) | |
| return out | |
| def build_management(rt: dict, page: int) -> list[IdaSalaryRecord]: | |
| """Tabel 14: position × management-span band (recovers management_span_band).""" | |
| out = [] | |
| titles = rt["panel_titles"] | |
| for row in rt["rows"]: | |
| band = parse_span(row["label"]) | |
| if band is None: | |
| continue | |
| lo, hi = SPAN_BOUNDS[band] | |
| for pi, m in enumerate(row["panels"]): | |
| if "mean" not in m or _is_alle(titles[pi]): | |
| continue | |
| tnorm = norm(titles[pi]) | |
| pos = next((p for kw, p in _PRIVATE_POSITION if kw in tnorm), None) | |
| if pos is None: | |
| continue | |
| n = m.get("count") | |
| dims = ["position_level", "management_span_band", "manages_people", "is_leader"] | |
| out.append(IdaSalaryRecord( | |
| **_base("private", page), table_id=14, table_title="PERSONALEANSVAR", | |
| pay_concept="gross_monthly", data_period_month="2025-09", | |
| position_level=pos, position_level_rank=POSITION_RANK.get(pos), | |
| management_span_band=band, span_min=lo, span_max=hi, | |
| manages_people="no" if band == "none" else "yes", | |
| is_leader="leader", | |
| row_dimension="stillingstype × personaleansvar", row_label=row["label"], | |
| segment_label=titles[pi], | |
| dimension_keys=dims, specificity=len(dims), | |
| sample_size=n, reliability_tier=reliability_tier(n), | |
| measure=Measure(count=n, mean=m.get("mean")), rag_text="", | |
| )) | |
| return out | |
| # Page 22 stacks tabel 14 (personaleansvar) and tabel 15 (virksomhedsstørrelse) | |
| # on one page; the single stat-band detector can't separate them and reads | |
| # garbage counts (the README documents these matrix tables as unreliable). | |
| # management_span_band / stem_count_band are "captured, don't filter" in v1, so | |
| # we deliberately omit page 22 rather than emit bad data. build_management is | |
| # kept for a future dedicated parser. | |
| _TABLES = [ | |
| (18, build_region), (25, build_gender), (30, build_public_position), | |
| ] | |
| def main() -> None: | |
| records: list[IdaSalaryRecord] = [] | |
| with pdfplumber.open(IDA_PDF) as pdf: | |
| for page, builder in _TABLES: | |
| rt = extract_panels(pdf.pages[page - 1]) | |
| if rt is None: | |
| print(f" p{page}: no panel structure found — skipped") | |
| continue | |
| recs = builder(rt, page) | |
| print(f" p{page} {builder.__name__}: {len(recs)} records " | |
| f"({len(rt['panel_titles'])} panels: {rt['panel_titles']})") | |
| records += recs | |
| # stable ids | |
| for i, r in enumerate(records): | |
| r.id = f"ida-reextract-{r.table_id}-{i:04d}" | |
| OUT.write_text( | |
| "\n".join(r.model_dump_json(exclude_none=True) for r in records) + "\n", | |
| encoding="utf-8", | |
| ) | |
| print(f"OK — {len(records)} re-extracted records → {OUT}") | |
| if __name__ == "__main__": | |
| main() | |