Skip to content

Commit 1c1537f

Browse files
committed
feat(structural): source-specific Jacobian + snapshot identification-contribution report
Identification-targeted diagnostics over the genuine audited dataset (no new source/model/threshold/terminology; nothing fabricated). - source_jacobian_ranks: per-family Jacobians J_UYAP/J_KAP/J_TOKI restricted to each family's genuinely OBSERVED moments + J_combined; reports numerical rank of each. Answers 'which source adds an independent parameter direction' (a LOCAL moment-sensitivity diagnostic, NOT causal or standalone identification). - build_observed_moments now returns per-moment sample_sizes; identification_report adds source_jacobians, sample_sizes and smallest-non-zero singular value. - snapshot before->after comparison (snapshot_metrics/save_snapshot/load_snapshot/compare_snapshots): newly-unlocked moments, sample-size increases, rank / smallest-nonzero-sv / condition-number / status deltas. CLI: sold structural identify --save-snapshot. - MEASURED on the current genuine data (validation/structural/): J_UYAP rank 1 (uyap_sale_prob degenerate at 1.0), J_KAP rank 1, J_TOKI rank 0 (single disclosure, no cohort), J_combined rank 2 -> still NOT_IDENTIFIED (dim 6). Baseline saved to validation/structural/identify_snapshot.json. - EPISTEMIC: no additional genuine audited records were available or fabricated, so toki_cohort_moments / uyap_win-over-appraisal_sd / kap_log_ratio_sd stay UNAVAILABLE and status is unchanged. The moment-unlock machinery is verified with clearly-labelled FIXTURES (not counted in the genuine report): a 2nd sold auction unlocks the win/appraisal sd, an unsold auction makes sale_prob non-degenerate, two consecutive PMVR3 disclosures unlock a TOKI period cohort, a 2nd KAP negotiated disposal unlocks the log-ratio sd. - Estimation gate unchanged; theta/optimizer/mechanism/thresholds/terminology untouched; Level-2 records + consumer path frozen. - 175 tests passing
1 parent e8397ee commit 1c1537f

7 files changed

Lines changed: 310 additions & 5 deletions

File tree

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
[![Data refresh](https://github.com/onatozmenn/sold/actions/workflows/kfe-refresh.yml/badge.svg)](https://github.com/onatozmenn/sold/actions/workflows/kfe-refresh.yml)
55
[![Python](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/)
66
[![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
7-
[![Tests](https://img.shields.io/badge/tests-169%20passing-brightgreen.svg)](tests/)
7+
[![Tests](https://img.shields.io/badge/tests-175%20passing-brightgreen.svg)](tests/)
88
[![Data](https://img.shields.io/badge/data-TCMB%20%C2%B7%20T%C3%9C%C4%B0K-informational.svg)](#data-sources)
99

1010
> Infer the **realized transaction price** of a Turkish home from its **asking** price — a provenance-aware valuation engine.
@@ -301,7 +301,7 @@ tests/ # offline unit / end-to-end tests
301301
## Testing
302302

303303
```bash
304-
pytest -q # 169 tests, fully offline (no network or API key required)
304+
pytest -q # 175 tests, fully offline (no network or API key required)
305305
```
306306

307307
## Methodology & References
@@ -326,7 +326,8 @@ Negotiation-margin figures from Turkish market reporting: İstanbul ≈ 10%, Ank
326326
- [ ] **First genuine real-world label** — exactly one *actual* seller-submitted completed residential sale passing through the product path + quality gate. **Current genuine direct-label count: 0** — the end-to-end test proves the acquisition *path* works, not that a real-world label has been acquired
327327
- [x] **Structural econometric core** — mechanism-aware generalized **Nash bargaining** (`P = ηB+(1−η)S`, `η` estimated, not hard-coded) fit by **Simulated Method of Moments**; TCMB-anchored hedonic fair value (relative premiums only, no listing intercept as level); structural UYAP auctions with the **statutory legal floor** (`muhammen_bedel` preserved as appraised value `Q`, never the reserve; partially-observed floors not fabricated); KAP `η`-calibration moments with a corporate mechanism shift; TOKİ cumulative-disclosure differencing into room-type cohort moments. Replaces weak-label aggregation as the core; the provenance registry and validated KAP/TOKİ/UYAP Level-2 records are kept; the consumer path is frozen as an optional future *validation* channel. No SaleProbability model yet
328328
- [x] **Statutory-floor fix, TCMB double-count audit & identification diagnostics** — corrected the İİK acceptance floor to `max(0.5·Q, priority_claims) + realization_costs`; audited the fair-value anchor so a contemporaneous TL/m² level is not trend-adjusted twice (KFE ratio only when rolling an older anchor); expanded the UYAP/KAP/TOKİ structural dataset schemas + observed-moment constructors (area semantics preserved, `log(sale/appraisal)` KAP moments, revision-guarded TOKİ cohorts); added `sold structural identify` (Jacobian rank / singular values / condition number / weak directions / profile diagnostics → `NOT_IDENTIFIED` → sensitivity mode)
329-
- [x] **Genuine structural dataset (measured, not synthetic)** — wired the three validated Level-2 records as the genuine audited seed under [`validation/structural/`](validation/structural/) (`source_audited=true`, kept distinct from fixtures): **1 UYAP auction** (sold; appraised `Q`=4.5M → winning 4.545M; parcel/net areas distinct, gross null; partial legal floor), **1 KAP negotiated disposal** (prior-appraisal 5.2M → sale 5.508M; `value_method=negotiation`), **1 TOKİ disclosure** (PMVR3 → **0** derivable period cohorts: differencing needs ≥2 consecutive disclosures). `m_obs` is rebuilt from this genuine data — **3 single-observation means** (`uyap_sale_prob`, `uyap_win/appraisal`, `kap log(sale/appraisal)`); variances/quantiles reported **unavailable** at n=1 (never guessed). Measured result: `sold structural dataset` + `sold structural identify` → **`NOT_IDENTIFIED`** (rank 2 / dim 6, condition number →∞), sensitivity mode; a three-way `WEAKLY_IDENTIFIED` status and moment-provenance/unavailability reporting were added. **Next: genuine repeated TOKİ disclosures + more audited UYAP/KAP observations → rerun identify (data, not architecture)**
329+
- [x] **Genuine structural dataset (measured, not synthetic)** — wired the three validated Level-2 records as the genuine audited seed under [`validation/structural/`](validation/structural/) (`source_audited=true`, kept distinct from fixtures): **1 UYAP auction** (sold; appraised `Q`=4.5M → winning 4.545M; parcel/net areas distinct, gross null; partial legal floor), **1 KAP negotiated disposal** (prior-appraisal 5.2M → sale 5.508M; `value_method=negotiation`), **1 TOKİ disclosure** (PMVR3 → **0** derivable period cohorts: differencing needs ≥2 consecutive disclosures). `m_obs` is rebuilt from this genuine data — **3 single-observation means** (`uyap_sale_prob`, `uyap_win/appraisal`, `kap log(sale/appraisal)`); variances/quantiles reported **unavailable** at n=1 (never guessed). Measured result: `sold structural dataset` + `sold structural identify` → **`NOT_IDENTIFIED`** (rank 2 / dim 6, condition number →∞), sensitivity mode; a three-way `WEAKLY_IDENTIFIED` status and moment-provenance/unavailability reporting were added
330+
- [x] **Identification-contribution diagnostics (source-specific Jacobian + snapshots)** — `sold structural identify` reports per-source Jacobian ranks (`J_UYAP`/`J_KAP`/`J_TOKİ`/`J_combined`) restricted to each family's genuinely observed moments — answering *which source adds an independent parameter direction* (a local moment-sensitivity diagnostic, **not** causal or standalone identification) — plus a `--save-snapshot` before→after comparison (newly-unlocked moments, sample-size increases, rank / smallest-non-zero-singular-value / condition-number / status deltas). **Measured on the current genuine data: `J_UYAP` rank 1 (`uyap_sale_prob` degenerate at 1.0), `J_KAP` rank 1, `J_TOKİ` rank 0 (single disclosure, no cohort), `J_combined` rank 2 → still `NOT_IDENTIFIED` (dim 6).** Unlocking `toki_cohort_moments` / `uyap_win-over-appraisal_sd` / `kap_log_ratio_sd` requires additional **genuine audited** records (a second consecutive PMVR3 disclosure; a second sold + one unsold UYAP auction; a second KAP negotiated disposal) — operator-supplied official records; none were fabricated, so those moments stay unavailable and the status is unchanged. The moment-unlock machinery is verified with clearly-labelled fixtures (not counted in the genuine report). **Next: operator-audited genuine records → rerun identify (data, not architecture)**
330331
- [ ] **SaleProbability** model (`P(sold ≤ N days)`) trained on collected outcomes
331332
- [ ] Live, ToS-reviewed fetchers for the public label sources
332333
- [ ] Broker-vs-benchmark analytics over an aggregate anonymized dataset

src/sold/cli.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1529,6 +1529,7 @@ def structural_identify_cmd(
15291529
kap_file: Optional[Path] = typer.Option(None, "--kap", help="KAP kayıtları (JSON)"),
15301530
toki_file: Optional[Path] = typer.Option(None, "--toki", help="TOKİ açıklamaları (JSON)"),
15311531
demo: bool = typer.Option(False, "--demo", help="Sentetik veriyle diagnostiği göster (gerçek değil)"),
1532+
save_snapshot_flag: bool = typer.Option(False, "--save-snapshot", help="Mevcut sonucu genuine snapshot olarak kaydet (karşılaştırma tabanı)"),
15321533
) -> None:
15331534
"""Yapısal KİMLİKLENDİRME raporu — VARSAYILAN olarak GERÇEK denetlenmiş veri kümesinden.
15341535
@@ -1543,24 +1544,30 @@ def structural_identify_cmd(
15431544

15441545
from .structural import (
15451546
DEFAULT_FREE,
1547+
GENUINE_DIR,
15461548
MomentContext,
15471549
StructuralParams,
15481550
build_observed_moments,
1551+
compare_snapshots,
15491552
context_from_datasets,
15501553
difference_disclosures,
15511554
identification_report,
15521555
load_auctions,
15531556
load_genuine_datasets,
15541557
load_kap_disposals,
1558+
load_snapshot,
15551559
observed_moments,
1560+
save_snapshot,
15561561
simulate_negotiations,
1562+
snapshot_metrics,
15571563
)
15581564

15591565
auctions_df = kap_df = None
15601566
toki_res = None
15611567
m_obs: dict = {}
15621568
provenance: dict = {}
15631569
unavailable: list = []
1570+
sample_sizes: dict = {}
15641571

15651572
if demo:
15661573
theta0 = StructuralParams(eta=0.6)
@@ -1587,12 +1594,13 @@ def structural_identify_cmd(
15871594
auctions_df, kap_df, toki_res = genuine["uyap"], genuine["kap"], genuine["toki_result"]
15881595
built = build_observed_moments(auctions_df, kap_df, toki_res)
15891596
m_obs, provenance, unavailable = built["moments"], built["provenance"], built["unavailable"]
1597+
sample_sizes = built["sample_sizes"]
15901598
ctx = context_from_datasets(auctions_df, kap_df)
15911599

15921600
rep = identification_report(
15931601
ctx, StructuralParams(), DEFAULT_FREE, m_obs=m_obs,
15941602
auctions=auctions_df, kap=kap_df, toki_result=toki_res,
1595-
provenance=provenance, unavailable=unavailable,
1603+
provenance=provenance, unavailable=unavailable, sample_sizes=sample_sizes,
15961604
)
15971605
ds = rep["dataset"]
15981606
typer.secho("Yapısal kimliklendirme raporu", fg=typer.colors.CYAN, bold=True)
@@ -1631,6 +1639,35 @@ def structural_identify_cmd(
16311639
flag = "ZAYIF" if pr.get("weakly_identified") else "belirgin"
16321640
rr_txt = f"{rr:.2e}" if isinstance(rr, (int, float)) else "—"
16331641
typer.echo(f" Profil {pname}: göreli hedef aralığı {rr_txt}{flag}")
1642+
# Kaynağa özgü Jacobian rank (hangi kaynak BAĞIMSIZ bir parametre yönü ekliyor?)
1643+
sj = rep.get("source_jacobians") or {}
1644+
if sj:
1645+
typer.secho(
1646+
" Kaynak-özgü Jacobian rank (yerel moment-duyarlılığı; NEDENSEL/tek-başına değil):",
1647+
fg=typer.colors.CYAN,
1648+
)
1649+
for fam in ("J_UYAP", "J_KAP", "J_TOKI", "J_combined"):
1650+
blk = sj.get(fam, {})
1651+
typer.echo(f" {fam}: rank {blk.get('rank', 0)} / moment {blk.get('n_moments', 0)}")
1652+
# Snapshot: kaydet ya da önceki genuine snapshot ile karşılaştır
1653+
snap_path = Path(GENUINE_DIR) / "identify_snapshot.json"
1654+
if save_snapshot_flag:
1655+
save_snapshot(rep, snap_path)
1656+
typer.secho(f" Snapshot kaydedildi: {snap_path}", fg=typer.colors.GREEN)
1657+
elif not demo:
1658+
prev = load_snapshot(snap_path)
1659+
if prev:
1660+
cmp = compare_snapshots(prev, snapshot_metrics(rep))
1661+
typer.secho(" Önceki genuine snapshot → mevcut (identification-katkı):", fg=typer.colors.CYAN)
1662+
typer.echo(f" Yeni açılan moment: {cmp['moments_newly_unlocked'] or '—'}")
1663+
inc = [c['moment'] for c in cmp['moments_sample_increased']]
1664+
typer.echo(f" Örneklem artan: {inc or '—'}")
1665+
typer.echo(f" rank: {cmp['rank']['before']}{cmp['rank']['after']}")
1666+
typer.echo(
1667+
f" en küçük ≠0 tekil değer: {cmp['smallest_nonzero_singular_value']['before']} → "
1668+
f"{cmp['smallest_nonzero_singular_value']['after']}"
1669+
)
1670+
typer.echo(f" durum: {cmp['status']['before']}{cmp['status']['after']}")
16341671
if rep["status"] != "IDENTIFIED":
16351672
typer.secho(
16361673
" Not: rank < dim veya zayıf kimliklendirme → optimizer sonucu NOKTA TAHMİNİ olarak "

src/sold/structural/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,15 @@
3939
)
4040
from .hedonic import HedonicPremium, roll_unit_price, tcmb_fair_value
4141
from .identify import (
42+
compare_snapshots,
4243
dataset_summary,
4344
identification_report,
45+
load_snapshot,
4446
moment_jacobian,
4547
profile_objective,
48+
save_snapshot,
49+
snapshot_metrics,
50+
source_jacobian_ranks,
4651
)
4752
from .kap import (
4853
KAP_FIELDS,
@@ -114,8 +119,13 @@
114119
# kimliklendirme (identification)
115120
"moment_jacobian",
116121
"identification_report",
122+
"source_jacobian_ranks",
117123
"dataset_summary",
118124
"profile_objective",
125+
"snapshot_metrics",
126+
"save_snapshot",
127+
"load_snapshot",
128+
"compare_snapshots",
119129
# tahmin
120130
"StructuralClosingPredictor",
121131
]

src/sold/structural/identify.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222

2323
from dataclasses import replace
2424

25+
import json
26+
from pathlib import Path
27+
2528
import numpy as np
2629
import pandas as pd
2730

@@ -78,6 +81,50 @@ def msim(x: np.ndarray) -> dict:
7881
return J, keys
7982

8083

84+
def _rank_and_svd(J: np.ndarray) -> dict:
85+
"""Bir Jacobian bloğunun sayısal rank'ı + tekil değerleri + en küçük sıfır-olmayan sv."""
86+
if J.size == 0 or J.shape[0] == 0:
87+
return {"rank": 0, "n_moments": int(J.shape[0]), "singular_values": [], "smallest_nonzero_sv": None}
88+
s = np.linalg.svd(J, compute_uv=False)
89+
smax = float(s.max()) if s.size else 0.0
90+
tol = max(J.shape) * np.finfo(float).eps * smax
91+
nz = s[s > tol]
92+
return {
93+
"rank": int((s > tol).sum()),
94+
"n_moments": int(J.shape[0]),
95+
"singular_values": [float(x) for x in s],
96+
"smallest_nonzero_sv": float(nz.min()) if nz.size else None,
97+
}
98+
99+
100+
def source_jacobian_ranks(
101+
params: StructuralParams,
102+
ctx: MomentContext,
103+
free_names: tuple[str, ...],
104+
m_obs: dict,
105+
provenance: dict | None = None,
106+
seed: int = 12345,
107+
step: float = JACOBIAN_STEP,
108+
) -> dict:
109+
"""Kaynağa özgü (J_UYAP / J_KAP / J_TOKİ) ve BİRLEŞİK Jacobian sayısal rank'ları.
110+
111+
Her aile YALNIZCA o ailenin katkı verdiği (gözlenen) momentlerin satırlarını kullanır.
112+
Amaç: "hangi kaynak BAĞIMSIZ bir parametre yönü ekliyor?" (sadece "hangi kaynak daha
113+
çok satır ekledi?" değil). NOT: kaynağa özgü rank NEDENSEL ya da TEK-BAŞINA parametre
114+
kimliklendirmesi DEĞİLDİR; mevcut yapısal model altında yerel moment-duyarlılığı tanısıdır.
115+
"""
116+
obs_keys = list(m_obs.keys()) if m_obs else None
117+
J, keys = moment_jacobian(params, ctx, free_names, seed=seed, step=step, moment_keys=obs_keys)
118+
prov = provenance or {}
119+
out: dict = {}
120+
for fam in ("uyap", "kap", "toki"):
121+
rows = [i for i, k in enumerate(keys) if prov.get(k) == fam]
122+
Jf = J[rows, :] if rows else np.zeros((0, len(free_names)))
123+
out[f"J_{fam.upper()}"] = _rank_and_svd(Jf)
124+
out["J_combined"] = _rank_and_svd(J)
125+
return out
126+
127+
81128
def _weight(obs: np.ndarray) -> np.ndarray:
82129
return np.diag(1.0 / (np.abs(obs) + 1e-3) ** 2)
83130

@@ -162,6 +209,7 @@ def identification_report(
162209
toki_result: dict | None = None,
163210
provenance: dict | None = None,
164211
unavailable: list | None = None,
212+
sample_sizes: dict | None = None,
165213
seed: int = 12345,
166214
step: float = JACOBIAN_STEP,
167215
) -> dict:
@@ -190,6 +238,7 @@ def identification_report(
190238
"moment_keys": keys,
191239
"moment_provenance": provenance or {},
192240
"unavailable_moments": unavailable or [],
241+
"sample_sizes": sample_sizes or {},
193242
"jacobian_step": step,
194243
}
195244

@@ -199,8 +248,10 @@ def identification_report(
199248
"status": "NOT_IDENTIFIED",
200249
"rank": 0,
201250
"singular_values": [],
251+
"smallest_nonzero_singular_value": None,
202252
"condition_number": float("inf"),
203253
"weakly_identified_directions": [],
254+
"source_jacobians": {},
204255
"prediction_mode": "sensitivity_mode",
205256
"reason": "no_observed_moments" if n_moments == 0 else "no_free_parameters",
206257
}
@@ -212,6 +263,8 @@ def identification_report(
212263
tol = max(J.shape) * np.finfo(float).eps * smax
213264
rank = int((s > tol).sum())
214265
cond = float(smax / s.min()) if s.size and s.min() > 0 else float("inf")
266+
nz = s[s > tol]
267+
smallest_nonzero_sv = float(nz.min()) if nz.size else None
215268

216269
# Zayıf yönler: küçük tekil değerlere karşılık gelen sağ-tekil vektörler
217270
_, s_full, vt = np.linalg.svd(J, full_matrices=False)
@@ -256,9 +309,83 @@ def identification_report(
256309
"status": status,
257310
"rank": rank,
258311
"singular_values": [float(x) for x in s],
312+
"smallest_nonzero_singular_value": smallest_nonzero_sv,
259313
"condition_number": cond,
260314
"weakly_identified_directions": weak_dirs,
315+
"source_jacobians": source_jacobian_ranks(
316+
params, ctx, free_names, m_obs or {}, provenance, seed=seed, step=step
317+
),
261318
"prediction_mode": mode,
262319
}
263320
)
264321
return report
322+
323+
324+
# --------------------------------------------------------------------------- #
325+
# Snapshot karşılaştırması — identification-KATKI raporu (yeni batch etkisi)
326+
# --------------------------------------------------------------------------- #
327+
def snapshot_metrics(report: dict) -> dict:
328+
"""Bir identification raporundan snapshot metriklerini çıkarır (önceki/sonraki kıyas)."""
329+
return {
330+
"n_structural_parameters": report.get("n_structural_parameters"),
331+
"n_observed_moments": report.get("n_observed_moments"),
332+
"available_moments": sorted((report.get("moment_provenance") or {}).keys()),
333+
"sample_sizes": report.get("sample_sizes", {}),
334+
"rank": report.get("rank"),
335+
"smallest_nonzero_singular_value": report.get("smallest_nonzero_singular_value"),
336+
"condition_number": report.get("condition_number"),
337+
"weak_directions": [
338+
wd.get("direction") for wd in report.get("weakly_identified_directions", [])
339+
],
340+
"status": report.get("status"),
341+
}
342+
343+
344+
def save_snapshot(report: dict, path) -> Path:
345+
path = Path(path)
346+
path.parent.mkdir(parents=True, exist_ok=True)
347+
path.write_text(
348+
json.dumps(snapshot_metrics(report), ensure_ascii=False, indent=2), encoding="utf-8"
349+
)
350+
return path
351+
352+
353+
def load_snapshot(path) -> dict | None:
354+
path = Path(path)
355+
if not path.exists():
356+
return None
357+
return json.loads(path.read_text(encoding="utf-8"))
358+
359+
360+
def compare_snapshots(before: dict, after: dict) -> dict:
361+
"""Önceki genuine snapshot ile mevcut arasındaki identification-KATKI değişimi.
362+
363+
'Kaç satır eklendi?' değil, 'hangi moment açıldı / hangi yön güçlendi?' sorusuna yanıt.
364+
"""
365+
b_moms = set(before.get("available_moments", []))
366+
a_moms = set(after.get("available_moments", []))
367+
ss_b = before.get("sample_sizes", {}) or {}
368+
ss_a = after.get("sample_sizes", {}) or {}
369+
increased = [
370+
{"moment": k, "before": int(ss_b.get(k, 0)), "after": int(ss_a.get(k, 0))}
371+
for k in sorted(set(ss_a) | set(ss_b))
372+
if int(ss_a.get(k, 0)) > int(ss_b.get(k, 0))
373+
]
374+
return {
375+
"moments_newly_unlocked": sorted(a_moms - b_moms),
376+
"moments_sample_increased": increased,
377+
"rank": {"before": before.get("rank"), "after": after.get("rank")},
378+
"smallest_nonzero_singular_value": {
379+
"before": before.get("smallest_nonzero_singular_value"),
380+
"after": after.get("smallest_nonzero_singular_value"),
381+
},
382+
"condition_number": {
383+
"before": before.get("condition_number"),
384+
"after": after.get("condition_number"),
385+
},
386+
"weak_directions": {
387+
"before": before.get("weak_directions"),
388+
"after": after.get("weak_directions"),
389+
},
390+
"status": {"before": before.get("status"), "after": after.get("status")},
391+
}

0 commit comments

Comments
 (0)