pay-equity-for-eu / scripts /reextract_multipanel.py
Franskaman110's picture
reextraction
b59aea7
Raw
History Blame Contribute Delete
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()