Skip to content

Commit 56c5e87

Browse files
fix: table parser honors escaped pipes; JS renderMarkdown matches heading offset
#552: build_explainers.py's split_row and explainers-ui.js's parseTable both did a naive split("|") on every pipe in a table row, with no support for GFM's "\|" escape. A literal pipe inside a cell (e.g. "P(S = 1 | X)") started a spurious column, shifting every later column - already visibly misrendering explainers/reject-inference.html (an IPW row with 6 <td> for a 4-column table) and explainers/base-rate-fallacy.html (a "PPV (P(Y = 1 | Ŷ = 1))" header split into two <th>). Both parsers now split on unescaped "|" only and unescape "\|" -> "|" in each cell. reject-inference.md's source pipes are now escaped as "\|" (base-rate-fallacy.md already had them escaped); pages regenerated. #553: a prior fix offset build_explainers.py's markdown headings by +1 (the page hero already renders a real <h1>), but explainers-ui.js's renderMarkdown was never updated and still emitted raw levels. It now applies the same Math.min(level + 1, 6). This path isn't reached in production today, but "fixing" it later under the assumption it matched the server renderer would have reintroduced the duplicate-<h1> bug. Closes #552 Closes #553
1 parent 9cd70fc commit 56c5e87

8 files changed

Lines changed: 71 additions & 9 deletions

File tree

assets/explainers-ui.js

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,14 @@
109109
return null;
110110
}
111111

112-
const headers = rows[0].split('|').slice(1, -1).map(cell => cell.trim());
113-
const bodyRows = rows.slice(2).map(row => row.split('|').slice(1, -1).map(cell => cell.trim()));
112+
// Split on unescaped "|" only, then unescape "\|" -> "|" in each cell, so
113+
// a literal pipe inside a cell (GFM's "\|") no longer starts a spurious
114+
// column. Mirrors scripts/build_explainers.py's split_row().
115+
const splitRow = row => row.trim().split(/(?<!\\)\|/).slice(1, -1)
116+
.map(cell => cell.trim().replace(/\\\|/g, '|'));
117+
118+
const headers = splitRow(rows[0]);
119+
const bodyRows = rows.slice(2).map(splitRow);
114120

115121
const headerHtml = headers.map(cell => `<th>${inlineMarkdown(cell)}</th>`).join('');
116122
const bodyHtml = bodyRows.map(row => `<tr>${row.map(cell => `<td>${inlineMarkdown(cell)}</td>`).join('')}</tr>`).join('');
@@ -214,7 +220,9 @@
214220
flushParagraph();
215221
flushList();
216222
flushQuote();
217-
const level = headingMatch[1].length;
223+
// +1 offset: the explainer page's hero already renders a real <h1>.
224+
// Mirrors scripts/build_explainers.py's render_markdown().
225+
const level = Math.min(headingMatch[1].length + 1, 6);
218226
const headingText = headingMatch[2];
219227
const baseId = slugifyHeading(headingText);
220228
const nextCount = (headingCounts.get(baseId) || 0) + 1;

explainers/base-rate-fallacy.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@ <h3 id="the-mathematics-of-the-base-rate-fallacy">The Mathematics of the Base Ra
205205
<pre><code>PPV = P(Y = 1 | Ŷ = 1) = (TPR * p) / (TPR * p + FPR * (1 - p))</code></pre>
206206
<h4 id="prevalence-impact-on-reliability">Prevalence Impact on Reliability</h4>
207207
<p>To see how background prevalence dictates prediction reliability, consider a screening model with fixed TPR = 0.90 and FPR = 0.10 evaluated across varying base rates (p):</p>
208-
<div class="explainer-table-wrap"><table class="explainer-table"><thead><tr><th>Base Rate (p)</th><th>True Positives (TPR * p)</th><th>False Positives (FPR * (1 - p))</th><th>PPV (P(Y = 1 \</th><th>Ŷ = 1))</th><th>False Discovery Rate (1 - PPV)</th></tr></thead><tbody><tr><td><strong>1%</strong></td><td>0.0090</td><td>0.0990</td><td><strong>8.33%</strong></td><td><strong>91.67%</strong></td></tr><tr><td><strong>5%</strong></td><td>0.0450</td><td>0.0950</td><td><strong>32.14%</strong></td><td><strong>67.86%</strong></td></tr><tr><td><strong>10%</strong></td><td>0.0900</td><td>0.0900</td><td><strong>50.00%</strong></td><td><strong>50.00%</strong></td></tr><tr><td><strong>30%</strong></td><td>0.2700</td><td>0.0700</td><td><strong>79.41%</strong></td><td><strong>20.59%</strong></td></tr><tr><td><strong>50%</strong></td><td>0.4500</td><td>0.0500</td><td><strong>90.00%</strong></td><td><strong>10.00%</strong></td></tr></tbody></table></div>
208+
<div class="explainer-table-wrap"><table class="explainer-table"><thead><tr><th>Base Rate (p)</th><th>True Positives (TPR * p)</th><th>False Positives (FPR * (1 - p))</th><th>PPV (P(Y = 1 | Ŷ = 1))</th><th>False Discovery Rate (1 - PPV)</th></tr></thead><tbody><tr><td><strong>1%</strong></td><td>0.0090</td><td>0.0990</td><td><strong>8.33%</strong></td><td><strong>91.67%</strong></td></tr><tr><td><strong>5%</strong></td><td>0.0450</td><td>0.0950</td><td><strong>32.14%</strong></td><td><strong>67.86%</strong></td></tr><tr><td><strong>10%</strong></td><td>0.0900</td><td>0.0900</td><td><strong>50.00%</strong></td><td><strong>50.00%</strong></td></tr><tr><td><strong>30%</strong></td><td>0.2700</td><td>0.0700</td><td><strong>79.41%</strong></td><td><strong>20.59%</strong></td></tr><tr><td><strong>50%</strong></td><td>0.4500</td><td>0.0500</td><td><strong>90.00%</strong></td><td><strong>10.00%</strong></td></tr></tbody></table></div>
209209
<p>At a 1% base rate, <strong>over 91% of flagged individuals are false alarms</strong>, despite the model having 90% sensitivity and 90% specificity.</p>
210210
<h4 id="the-chouldechova-impossibility-identity">The Chouldechova Impossibility Identity</h4>
211211
<p>When evaluating models across demographic groups A and B, Chouldechova (2017) demonstrated that the false positive rate (FPR), false negative rate (FNR), positive predictive value (PPV), and base rate (p) are linked by a strict identity:</p>

explainers/reject-inference.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,7 @@ <h4 id="the-missingness-mechanism-missing-not-at-random-mnar">The Missingness Me
226226
<p>If a bank historically required younger applicants to meet a higher credit bar than older applicants, then the younger applicants present in the approved dataset (<code>S = 1</code>) represent an artificially selected, ultra-qualified subset of all young applicants. A model trained on this sample will overestimate the credit standards required for young borrowers to succeed.</p>
227227
<h4 id="core-reject-inference-techniques">Core Reject Inference Techniques</h4>
228228
<p>Practitioners use four main statistical approaches to correct for reject inference:</p>
229-
<div class="explainer-table-wrap"><table class="explainer-table"><thead><tr><th>Method</th><th>Core Mechanism</th><th>Strengths</th><th>Key Vulnerability</th></tr></thead><tbody><tr><td><strong>Hard Parceling (Pseudo-Labeling)</strong></td><td>Train initial model M1 on approved cases (S = 1); score rejected cases (S = 0); assign binary labels Y_hat via threshold; retrain M2 on all rows.</td><td>Simple to implement in standard ML pipelines.</td><td>Propagates initial model errors and thresholding artifacts into retraining.</td></tr><tr><td><strong>Soft Parceling / Fuzzy Augmentation</strong></td><td>Assign continuous predicted probability p_hat = M1(X) as soft targets or weights for rejected cases.</td><td>Avoids hard threshold cutoffs; preserves prediction uncertainty.</td><td>Dilutes training signal if initial model probability estimates are miscalibrated.</td></tr><tr><td><strong>Inverse Probability Weighting (IPW)</strong></td><td>Estimate selection propensity w(X) = P(S = 1</td><td>X); weight approved cases by 1 / w(X) during training.</td><td>Theoretically unbiased under Missing At Random (MAR) assumptions.</td><td>Extreme weights when propensity P(S = 1</td><td>X) ≈ 0 create high estimator variance.</td></tr><tr><td><strong>Heckman Two-Stage Model</strong></td><td>Stage 1: Fit probit model for selection S. Stage 2: Add Inverse Mills Ratio λ(Zγ) to outcome model to absorb correlation ρ(u, ε).</td><td>Explicitly models unobserved selection correlation ρ.</td><td>Relies heavily on bivariate normality and valid exclusion restrictions (Z).</td></tr></tbody></table></div>
229+
<div class="explainer-table-wrap"><table class="explainer-table"><thead><tr><th>Method</th><th>Core Mechanism</th><th>Strengths</th><th>Key Vulnerability</th></tr></thead><tbody><tr><td><strong>Hard Parceling (Pseudo-Labeling)</strong></td><td>Train initial model M1 on approved cases (S = 1); score rejected cases (S = 0); assign binary labels Y_hat via threshold; retrain M2 on all rows.</td><td>Simple to implement in standard ML pipelines.</td><td>Propagates initial model errors and thresholding artifacts into retraining.</td></tr><tr><td><strong>Soft Parceling / Fuzzy Augmentation</strong></td><td>Assign continuous predicted probability p_hat = M1(X) as soft targets or weights for rejected cases.</td><td>Avoids hard threshold cutoffs; preserves prediction uncertainty.</td><td>Dilutes training signal if initial model probability estimates are miscalibrated.</td></tr><tr><td><strong>Inverse Probability Weighting (IPW)</strong></td><td>Estimate selection propensity w(X) = P(S = 1 | X); weight approved cases by 1 / w(X) during training.</td><td>Theoretically unbiased under Missing At Random (MAR) assumptions.</td><td>Extreme weights when propensity P(S = 1 | X) ≈ 0 create high estimator variance.</td></tr><tr><td><strong>Heckman Two-Stage Model</strong></td><td>Stage 1: Fit probit model for selection S. Stage 2: Add Inverse Mills Ratio λ(Zγ) to outcome model to absorb correlation ρ(u, ε).</td><td>Explicitly models unobserved selection correlation ρ.</td><td>Relies heavily on bivariate normality and valid exclusion restrictions (Z).</td></tr></tbody></table></div>
230230
<hr>
231231
<h3 id="concrete-example-german-credit-lending-audit-03">Concrete Example: German Credit Lending - Audit 03</h3>
232232
<p><a href="../index.html#project-credit"><code>German Credit Lending/credit_customers.csv</code></a> is the dataset behind Audit 03 in this repository. Its <code>class</code> column contains exactly two values across all 1,000 rows: <code>good</code> (700 rows) and <code>bad</code> (300 rows).</p>

explainers/reject-inference.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ Practitioners use four main statistical approaches to correct for reject inferen
6565
|---|---|---|---|
6666
| **Hard Parceling (Pseudo-Labeling)** | Train initial model M1 on approved cases (S = 1); score rejected cases (S = 0); assign binary labels Y_hat via threshold; retrain M2 on all rows. | Simple to implement in standard ML pipelines. | Propagates initial model errors and thresholding artifacts into retraining. |
6767
| **Soft Parceling / Fuzzy Augmentation** | Assign continuous predicted probability p_hat = M1(X) as soft targets or weights for rejected cases. | Avoids hard threshold cutoffs; preserves prediction uncertainty. | Dilutes training signal if initial model probability estimates are miscalibrated. |
68-
| **Inverse Probability Weighting (IPW)** | Estimate selection propensity w(X) = P(S = 1 | X); weight approved cases by 1 / w(X) during training. | Theoretically unbiased under Missing At Random (MAR) assumptions. | Extreme weights when propensity P(S = 1 | X) ≈ 0 create high estimator variance. |
68+
| **Inverse Probability Weighting (IPW)** | Estimate selection propensity w(X) = P(S = 1 \| X); weight approved cases by 1 / w(X) during training. | Theoretically unbiased under Missing At Random (MAR) assumptions. | Extreme weights when propensity P(S = 1 \| X) ≈ 0 create high estimator variance. |
6969
| **Heckman Two-Stage Model** | Stage 1: Fit probit model for selection S. Stage 2: Add Inverse Mills Ratio λ(Zγ) to outcome model to absorb correlation ρ(u, ε). | Explicitly models unobserved selection correlation ρ. | Relies heavily on bivariate normality and valid exclusion restrictions (Z). |
7070

7171
---

faircode/_explainers/reject-inference.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ Practitioners use four main statistical approaches to correct for reject inferen
6565
|---|---|---|---|
6666
| **Hard Parceling (Pseudo-Labeling)** | Train initial model M1 on approved cases (S = 1); score rejected cases (S = 0); assign binary labels Y_hat via threshold; retrain M2 on all rows. | Simple to implement in standard ML pipelines. | Propagates initial model errors and thresholding artifacts into retraining. |
6767
| **Soft Parceling / Fuzzy Augmentation** | Assign continuous predicted probability p_hat = M1(X) as soft targets or weights for rejected cases. | Avoids hard threshold cutoffs; preserves prediction uncertainty. | Dilutes training signal if initial model probability estimates are miscalibrated. |
68-
| **Inverse Probability Weighting (IPW)** | Estimate selection propensity w(X) = P(S = 1 | X); weight approved cases by 1 / w(X) during training. | Theoretically unbiased under Missing At Random (MAR) assumptions. | Extreme weights when propensity P(S = 1 | X) ≈ 0 create high estimator variance. |
68+
| **Inverse Probability Weighting (IPW)** | Estimate selection propensity w(X) = P(S = 1 \| X); weight approved cases by 1 / w(X) during training. | Theoretically unbiased under Missing At Random (MAR) assumptions. | Extreme weights when propensity P(S = 1 \| X) ≈ 0 create high estimator variance. |
6969
| **Heckman Two-Stage Model** | Stage 1: Fit probit model for selection S. Stage 2: Add Inverse Mills Ratio λ(Zγ) to outcome model to absorb correlation ρ(u, ε). | Explicitly models unobserved selection correlation ρ. | Relies heavily on bivariate normality and valid exclusion restrictions (Z). |
7070

7171
---

llms-full.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9235,7 +9235,7 @@ Practitioners use four main statistical approaches to correct for reject inferen
92359235
|---|---|---|---|
92369236
| **Hard Parceling (Pseudo-Labeling)** | Train initial model M1 on approved cases (S = 1); score rejected cases (S = 0); assign binary labels Y_hat via threshold; retrain M2 on all rows. | Simple to implement in standard ML pipelines. | Propagates initial model errors and thresholding artifacts into retraining. |
92379237
| **Soft Parceling / Fuzzy Augmentation** | Assign continuous predicted probability p_hat = M1(X) as soft targets or weights for rejected cases. | Avoids hard threshold cutoffs; preserves prediction uncertainty. | Dilutes training signal if initial model probability estimates are miscalibrated. |
9238-
| **Inverse Probability Weighting (IPW)** | Estimate selection propensity w(X) = P(S = 1 | X); weight approved cases by 1 / w(X) during training. | Theoretically unbiased under Missing At Random (MAR) assumptions. | Extreme weights when propensity P(S = 1 | X) ≈ 0 create high estimator variance. |
9238+
| **Inverse Probability Weighting (IPW)** | Estimate selection propensity w(X) = P(S = 1 \| X); weight approved cases by 1 / w(X) during training. | Theoretically unbiased under Missing At Random (MAR) assumptions. | Extreme weights when propensity P(S = 1 \| X) ≈ 0 create high estimator variance. |
92399239
| **Heckman Two-Stage Model** | Stage 1: Fit probit model for selection S. Stage 2: Add Inverse Mills Ratio λ(Zγ) to outcome model to absorb correlation ρ(u, ε). | Explicitly models unobserved selection correlation ρ. | Relies heavily on bivariate normality and valid exclusion restrictions (Z). |
92409240

92419241
---

scripts/build_explainers.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,12 @@ def parse_table(lines, start_index):
142142
return None
143143

144144
def split_row(row):
145-
return [cell.strip() for cell in row.split("|")[1:-1]]
145+
# Split on unescaped "|" only, then unescape "\|" -> "|" in each cell,
146+
# so a literal pipe inside a cell (GFM's "\|") no longer starts a
147+
# spurious column. Rows carry a leading and trailing "|", so the
148+
# first and last split fragments are empty and dropped.
149+
cells = re.split(r"(?<!\\)\|", row.strip())[1:-1]
150+
return [cell.strip().replace("\\|", "|") for cell in cells]
146151

147152
headers = split_row(rows[0])
148153
body_rows = [split_row(row) for row in rows[2:]]

tests/test_build_explainers.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,24 @@
11
import importlib
22
import json
3+
from pathlib import Path
4+
5+
REPO_ROOT = Path(__file__).resolve().parent.parent
6+
7+
8+
def test_explainers_ui_js_stays_in_render_parity_with_build_explainers():
9+
# assets/explainers-ui.js's renderMarkdown/parseTable is a client-side
10+
# port of scripts/build_explainers.py's render_markdown/split_row, but
11+
# nothing exercises it on a real explainer page, so it has silently
12+
# drifted before (#552, #553). Source-level guard (same idea as
13+
# test_js_parity's DOM-renderer checks) that the two fixes are present in
14+
# the JS too.
15+
js = (REPO_ROOT / "assets" / "explainers-ui.js").read_text(encoding="utf-8")
16+
17+
# #553: +1 heading offset, capped at h6
18+
assert "Math.min(headingMatch[1].length + 1, 6)" in js
19+
# #552: split table rows on unescaped "|" and unescape "\|" -> "|"
20+
assert r"/(?<!\\)\|/" in js
21+
assert r"replace(/\\\|/g, '|')" in js
322

423

524
def test_build_package_mirror_copies_data_and_markdown(tmp_path, monkeypatch):
@@ -84,3 +103,33 @@ def test_parse_table_still_accepts_three_dash_separator_row():
84103
assert result is not None
85104
headers, _body_rows, _next_index = result
86105
assert headers == ["A", "B"]
106+
107+
108+
def test_parse_table_honors_escaped_pipe_inside_a_cell():
109+
# A literal pipe in a cell must be written "\|" (GFM) and must not start a
110+
# new column; the "\" is stripped in the rendered cell. reject-inference.md
111+
# and base-rate-fallacy.md both hit this (#552).
112+
script = importlib.import_module("scripts.build_explainers")
113+
lines = [
114+
"| Method | Formula |",
115+
"|---|---|",
116+
r"| IPW | w(X) = P(S = 1 \| X) |",
117+
]
118+
119+
headers, body_rows, _ = script.parse_table(lines, 0)
120+
121+
assert headers == ["Method", "Formula"]
122+
assert body_rows == [["IPW", "w(X) = P(S = 1 | X)"]]
123+
124+
125+
def test_render_markdown_offsets_heading_levels_by_one():
126+
# The explainer page's hero already renders a real <h1>, so the markdown
127+
# body's headings are shifted down one level (h1 -> h2, capped at h6).
128+
# assets/explainers-ui.js's renderMarkdown must match this (#553).
129+
script = importlib.import_module("scripts.build_explainers")
130+
131+
html = script.render_markdown("# Top\n\n## Sub\n\n###### Deep\n", set())
132+
133+
assert '<h2 id="top">Top</h2>' in html
134+
assert '<h3 id="sub">Sub</h3>' in html
135+
assert '<h6 id="deep">Deep</h6>' in html # h6 + 1 stays h6, not h7

0 commit comments

Comments
 (0)