#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 合議庭穩定度與釋憲結果 ====================== python _pipeline\\合議庭穩定度.py "C:\\Users\\JudyLin\\Documents\\DisposedCase_多年期" 前置:先跑過 串接案件歷程.py 與 法官網絡.py,且 40_案件歷程/ 內已有 釋憲結果.csv。 三個穩定度指標(皆以法官在 103–114 年度的**全部**案件為分母,非僅釋憲原因案件): panel_n 該案合議庭同一組法官,在全期共同承審之案件數 pair_mean 該案各對法官共事案件數之平均 hhi_mean 該案三位法官各自「搭檔集中度」之平均。 單一法官之 HHI = Σ(某搭檔共事數 / 該法官總共事數)^2, 介於 0 與 1,越接近 1 表示長期固定與同一批人合審。 設計上的兩個限制,務必連同結果一起讀: 1. 釋憲結果由大法官決定,不是由原審合議庭決定。本分析只能問 「被聲請釋憲的案件中,原審庭的穩定度是否與後來的違憲宣告有關」, 不能推論因果。可能的機制是間接的,例如固定庭較易形成一致見解、 而該見解剛好是後來被宣告違憲的那一種。 2. 對照組為同法院同年度同案由之未被聲請案件,僅控制這三項, 未控制案件難度、當事人資力、爭點類型。組間差異不等於選案效果。 輸出於 40_案件歷程/: 合議庭穩定度_逐案.csv 每件原因案件的三項指標 合議庭穩定度_對照.csv 被聲請 vs 未被聲請之比較 穩定度與釋憲結果.csv 依釋憲結果分組之指標 00_索引/穩定度分析報告.md """ import csv, glob, os, sys, math, collections, random from pathlib import Path csv.field_size_limit(10 ** 9) random.seed(20260727) ROOT = Path(__file__).resolve().parent.parent D = ROOT / "40_案件歷程" SELF = [("c0_案號-年", "c0_案號-字別", "c0_案號-號"), ("c0_結案案號-年", "c0_結案案號-字別", "c0_結案案號-號")] CAP_PER_CELL = 3000 # 每個 法院×年度×案由 對照格最多保留的案件數 def norm_court(name): import re n = (name or "").strip().replace("台", "臺") n = re.sub(r"^臺灣(?=[\u4e00-\u9fff]*地方法院)", "", n) return n def load_targets(): jp = D / "法官參與.csv" ch = D / "案件歷程.csv" if not jp.exists() or not ch.exists(): print("請先執行 _pipeline\\串接案件歷程.py") sys.exit(1) judges = {r["法官"].strip() for r in csv.DictReader(open(jp, encoding="utf-8-sig")) if r.get("法官", "").strip()} pet, meta = set(), {} for r in csv.DictReader(open(ch, encoding="utf-8-sig")): if r["審級"] == "未命中": continue import re m = re.match(r"(.+?) (\d+)年度(.+?)字第(\d+)號", r["案號"]) if not m: continue k = (norm_court(m.group(1)), m.group(2), m.group(3), m.group(4)) pet.add(k) meta.setdefault(k, {"釋憲案": set(), "審級": r["審級"], "案由": r["案由"]}) meta[k]["釋憲案"].add(r["釋憲案"]) outcome = {} oc = D / "釋憲結果.csv" if oc.exists(): for r in csv.DictReader(open(oc, encoding="utf-8-sig")): outcome[r["釋憲案"]] = (r["R碼"], r["結果"]) print(f"目標法官 {len(judges)} 位;被聲請釋憲之原因案件 {len(pet)} 件;已編碼釋憲結果 {len(outcome)} 則") return judges, pet, meta, outcome def scan(dc, judges, pet): """一次掃過全部 CSV。保留:含目標法官之案件、被聲請案件、以及對照格內之案件。""" cells = {(k[0], k[1], None) for k in pet} # 法院×年(案由稍後比對) files = sorted(glob.glob(os.path.join(dc, "03_enriched", "*.csv"))) cases = {} # key -> dict cellbag = collections.defaultdict(list) for n, f in enumerate(files, 1): with open(f, encoding="utf-8-sig", newline="") as fh: r = csv.reader(fh) try: hdr = next(r) except StopIteration: continue ix = {h: i for i, h in enumerate(hdr)} sc = next((c for c in SELF if all(x in ix for x in c)), None) if not sc or "c0_法院別" not in ix or "c0_法官名1" not in ix: continue ci = ix["c0_法院別"]; yi, zi, ni = (ix[c] for c in sc) ji = [ix[c] for c in ("c0_法官名1", "c0_法官名2", "c0_法官名3") if c in ix] ai = ix.get("c0_案由"); ei = ix.get("c0_全案終結情形") for row in r: try: court = norm_court(row[ci]) key = (court, row[yi].strip(), row[zi].strip(), row[ni].strip()) except IndexError: continue if not key[3]: continue js = tuple(sorted({row[j].strip() for j in ji if j < len(row) and row[j].strip()})) if not js: continue is_pet = key in pet touch = is_pet or any(j in judges for j in js) cell = (court, key[1], row[ai].strip() if ai is not None and ai < len(row) else "") if not touch and (cell[0], cell[1], None) not in cells: continue if key in cases: continue rec = {"key": key, "judges": js, "pet": is_pet, "案由": cell[2], "終結情形": row[ei].strip() if ei is not None and ei < len(row) else ""} if touch: cases[key] = rec elif len(cellbag[cell]) < CAP_PER_CELL: cellbag[cell].append(rec) if n % 60 == 0: print(f" 掃描 {n}/{len(files)} 法官相關案件 {len(cases)} 對照池 {sum(len(v) for v in cellbag.values())}") return cases, cellbag def stability(cases): """以 cases 為母體計算搭檔分布與庭組合復現。""" pair = collections.Counter() panel = collections.Counter() jtot = collections.Counter() for rec in cases.values(): js = rec["judges"] if len(js) >= 2: panel[js] += 1 for j in js: jtot[j] += 1 for a in range(len(js)): for b in range(a + 1, len(js)): pair[(js[a], js[b])] += 1 partners = collections.defaultdict(collections.Counter) for (a, b), n in pair.items(): partners[a][b] += n partners[b][a] += n hhi = {} for j, c in partners.items(): s = sum(c.values()) hhi[j] = sum((v / s) ** 2 for v in c.values()) if s else 0.0 return pair, panel, jtot, hhi def metrics(rec, pair, panel, hhi): js = rec["judges"] if len(js) < 2: return None pn = panel.get(js, 0) ps = [pair.get((js[a], js[b]), 0) for a in range(len(js)) for b in range(a + 1, len(js))] pm = sum(ps) / len(ps) if ps else 0 hs = [hhi.get(j, 0.0) for j in js] return {"panel_n": pn, "pair_mean": round(pm, 2), "hhi_mean": round(sum(hs) / len(hs), 4), "n_judge": len(js)} def med(v): v = sorted(v) if not v: return 0 m = len(v) // 2 return v[m] if len(v) % 2 else (v[m - 1] + v[m]) / 2 def perm_test(a, b, iters=20000): """兩組中位數差的置換檢定,回傳雙尾 p。純 Python,不依賴 scipy。""" if not a or not b: return None obs = abs(med(a) - med(b)) pool = list(a) + list(b) na = len(a) hit = 0 for _ in range(iters): random.shuffle(pool) if abs(med(pool[:na]) - med(pool[na:])) >= obs - 1e-12: hit += 1 return round((hit + 1) / (iters + 1), 4) def main(): try: sys.stdout.reconfigure(encoding="utf-8") except Exception: pass if len(sys.argv) < 2: print("用法:python _pipeline\\合議庭穩定度.py ") sys.exit(1) dc = sys.argv[1] judges, pet, meta, outcome = load_targets() print("[1/3] 掃描終結案件資料(單次全掃)…") cases, cellbag = scan(dc, judges, pet) print(f" 法官相關案件 {len(cases)} 件;對照池 {sum(len(v) for v in cellbag.values())} 件") print("[2/3] 計算穩定度…") allcases = dict(cases) for v in cellbag.values(): for rec in v: allcases.setdefault(rec["key"], rec) pair, panel, jtot, hhi = stability(allcases) rows = [] for key, rec in allcases.items(): m = metrics(rec, pair, panel, hhi) if not m: continue rows.append({**m, "key": key, "法院": key[0], "年": key[1], "案號": f"{key[0]} {key[1]}年度{key[2]}字第{key[3]}號", "案由": rec["案由"], "終結情形": rec["終結情形"], "被聲請": 1 if rec["pet"] else 0, "法官": "、".join(rec["judges"]), "釋憲案": "、".join(sorted(meta.get(key, {}).get("釋憲案", []))) if rec["pet"] else ""}) P = [r for r in rows if r["被聲請"]] cellset = {(r["法院"], r["年"], r["案由"]) for r in P} C = [r for r in rows if not r["被聲請"] and (r["法院"], r["年"], r["案由"]) in cellset] print(f" 被聲請 {len(P)} 件;同法院同年度同案由之對照 {len(C)} 件") D.mkdir(exist_ok=True) with open(D / "合議庭穩定度_逐案.csv", "w", encoding="utf-8-sig", newline="") as fh: w = csv.DictWriter(fh, fieldnames=["案號", "法院", "年", "案由", "終結情形", "法官", "n_judge", "panel_n", "pair_mean", "hhi_mean", "被聲請", "釋憲案"], extrasaction="ignore") w.writeheader() for r in sorted(rows, key=lambda x: (-x["被聲請"], x["法院"], x["年"])): w.writerow(r) def desc(g, name): return {"組別": name, "件數": len(g), "panel_n中位": med([x["panel_n"] for x in g]), "pair_mean中位": med([x["pair_mean"] for x in g]), "hhi_mean中位": round(med([x["hhi_mean"] for x in g]), 4)} with open(D / "合議庭穩定度_對照.csv", "w", encoding="utf-8-sig", newline="") as fh: w = csv.DictWriter(fh, fieldnames=["組別", "件數", "panel_n中位", "pair_mean中位", "hhi_mean中位"]) w.writeheader(); w.writerow(desc(P, "被聲請釋憲")); w.writerow(desc(C, "未被聲請(同格對照)")) print("[3/3] 依釋憲結果分組…") VIO = {"R1", "R2", "R3", "R6"} CON = {"R4", "R5"} grp = collections.defaultdict(list) for r in P: codes = {outcome[k][0] for k in r["釋憲案"].split("、") if k in outcome} if not codes: g = "未編碼" elif codes & VIO: g = "違憲類(R1R2R3R6)" elif codes & CON: g = "合憲類(R4R5)" else: g = "其他(R7R8)" grp[g].append(r) with open(D / "穩定度與釋憲結果.csv", "w", encoding="utf-8-sig", newline="") as fh: w = csv.DictWriter(fh, fieldnames=["組別", "件數", "panel_n中位", "pair_mean中位", "hhi_mean中位"]) w.writeheader() for g in sorted(grp, key=lambda x: -len(grp[x])): w.writerow(desc(grp[g], g)) rep = ["# 合議庭穩定度與釋憲結果", "", "## 一、指標定義", "", "以法官在 103–114 年度之全部案件為分母計算,非僅釋憲原因案件。", "", "- `panel_n` 同一組合議庭法官共同承審之案件數", "- `pair_mean` 該案各對法官共事案件數之平均", "- `hhi_mean` 三位法官搭檔集中度(Herfindahl)之平均,0 至 1,越高越固定", "", "## 二、被聲請與未被聲請之比較", "", "對照組為**同法院、同年度、同案由**之未被聲請案件。", "", "| 組別 | 件數 | panel_n 中位 | pair_mean 中位 | hhi_mean 中位 |", "|---|---|---|---|---|"] for d in (desc(P, "被聲請釋憲"), desc(C, "未被聲請(同格對照)")): rep.append(f"| {d['組別']} | {d['件數']} | {d['panel_n中位']} | {d['pair_mean中位']} | {d['hhi_mean中位']} |") if P and C: rep += ["", "置換檢定(中位數差,雙尾,20000 次):", f"- panel_n p = {perm_test([x['panel_n'] for x in P], [x['panel_n'] for x in C])}", f"- pair_mean p = {perm_test([x['pair_mean'] for x in P], [x['pair_mean'] for x in C])}", f"- hhi_mean p = {perm_test([x['hhi_mean'] for x in P], [x['hhi_mean'] for x in C])}"] rep += ["", "## 三、依釋憲結果分組", "", "| 組別 | 件數 | panel_n 中位 | pair_mean 中位 | hhi_mean 中位 |", "|---|---|---|---|---|"] for g in sorted(grp, key=lambda x: -len(grp[x])): d = desc(grp[g], g) rep.append(f"| {d['組別']} | {d['件數']} | {d['panel_n中位']} | {d['pair_mean中位']} | {d['hhi_mean中位']} |") v = grp.get("違憲類(R1R2R3R6)", []); c = grp.get("合憲類(R4R5)", []) if v and c: rep += ["", "違憲類與合憲類之置換檢定:", f"- panel_n p = {perm_test([x['panel_n'] for x in v], [x['panel_n'] for x in c])}", f"- pair_mean p = {perm_test([x['pair_mean'] for x in v], [x['pair_mean'] for x in c])}", f"- hhi_mean p = {perm_test([x['hhi_mean'] for x in v], [x['hhi_mean'] for x in c])}"] rep += ["", "## 四、解讀上的限制", "", "1. 釋憲結果由大法官決定,非由原審合議庭決定。第三節之關聯**不得作因果解讀**," "至多是「被聲請案件中,原審庭穩定度與後來違憲宣告的共現關係」。", "2. 對照組僅控制法院、年度、案由三項,未控制案件難度、爭點類型、當事人資力。", "3. 樣本為串接成功者,早於 103 年度之裁判與官網未附裁判字號之釋憲案均不在內," "存在選擇性,不能外推至全部釋憲案。", "4. 每格對照案件上限 " + str(CAP_PER_CELL) + " 件,超過者截斷。", "5. 部分案件僅列一名法官(獨任或資料缺漏),已於 `n_judge` 標明," "panel_n 與 pair_mean 對此類案件意義有限。"] (ROOT / "00_索引").mkdir(exist_ok=True) (ROOT / "00_索引" / "穩定度分析報告.md").write_text("\n".join(rep) + "\n", encoding="utf-8") print("=" * 60) print("完成。報告:", ROOT / "00_索引" / "穩定度分析報告.md") print("=" * 60) if __name__ == "__main__": main()