Skip to content

Commit 9fb2f5c

Browse files
authored
Merge pull request #20 from DeepPSP/fix-make-risk-report
Fix errors in make risk report function
2 parents 7fa3882 + a2fdde6 commit 9fb2f5c

5 files changed

Lines changed: 106 additions & 55 deletions

File tree

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ repos:
1818
hooks:
1919
- id: flake8
2020
additional_dependencies: [pycodestyle>=2.11.0]
21-
args: [--max-line-length=128, '--exclude=./.*,build,dist', '--ignore=E501,W503,E231,E203,E251,E202', --count, --statistics, --show-source]
21+
args: [--max-line-length=128, '--exclude=./.*,build,dist', '--ignore=E501,W503,E231,E203,E251,E202,E226', --count, --statistics, --show-source]
2222
- repo: https://github.com/pycqa/isort
2323
rev: 7.0.0
2424
hooks:

CHANGELOG.rst

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,12 @@ Removed
2626
Fixed
2727
~~~~~
2828

29-
- Fix errors in edge cases (mainly when `n_total` equals `ref_total`)
29+
- (NOT fully resolved yet) Fix errors in edge cases (mainly when `n_total` equals `ref_total`)
3030
in the computation of difference of two proportions confidence intervals
3131
using `wang` method.
32+
- Fix errors in generating LaTeX table for risk report (function ``make_risk_report``):
33+
- Escape special characters in LaTeX.
34+
- Fix the header for the generated LaTeX table.
3235

3336
Security
3437
~~~~~~~~

diff_binom_confint/_applications.py

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import re
2+
import textwrap
13
import warnings
24
from pathlib import Path
35
from typing import Dict, Optional, Sequence, Tuple, Union
@@ -13,6 +15,27 @@
1315
]
1416

1517

18+
def _latex_escape(line: str) -> str:
19+
"""Escape LaTeX special characters in a string."""
20+
# LaTeX special characters that need escaping:
21+
# \ & % $ # _ { } ~ ^
22+
# We replace each occurrence with its escaped form.
23+
specials = {
24+
"\\": r"\textbackslash{}",
25+
"&": r"\&",
26+
"%": r"\%",
27+
"$": r"\$",
28+
"#": r"\#",
29+
"_": r"\_",
30+
"{": r"\{",
31+
"}": r"\}",
32+
"~": r"\textasciitilde{}",
33+
"^": r"\textasciicircum{}",
34+
}
35+
pattern = re.compile(r"([\\&%$#_{}~^])")
36+
return pattern.sub(lambda m: specials[m.group(1)], line)
37+
38+
1639
def make_risk_report(
1740
data_source: Union[pd.DataFrame, Tuple[pd.DataFrame, pd.DataFrame]],
1841
target: str,
@@ -220,7 +243,7 @@ def make_risk_report(
220243
n_positive[ref_item],
221244
n_affected[ref_item],
222245
conf_level,
223-
method,
246+
diff_method,
224247
**kwargs,
225248
).astuple(),
226249
}
@@ -283,17 +306,37 @@ def make_risk_report(
283306
if return_type.lower() == "pd":
284307
return df_risk_table
285308
elif return_type.lower() == "latex":
286-
rows = [line.replace("%", r"\%") for line in df_risk_table.to_latex(header=False, index=False).splitlines()]
287-
rows[0] = r"\begin{tabular}{@{\extracolsep{6pt}}lllllll@{}}"
288-
rows[2] = (
289-
r"\multicolumn{2}{l}{Feature} & \multicolumn{affected_cols}{l}{Affected} & \multicolumn{2}{l}{risk_name Risk ($95\%$ CI)} & risk_name Risk Difference ($95\%$ CI) \\ \cline{1-2}\cline{3-4}\cline{5-6}\cline{7-7}"
290-
)
291-
rows[2].replace("risk_name", risk_name).replace("95", str(int(conf_level * 100)))
309+
latex_body = df_risk_table.to_latex(header=False, index=False)
310+
body_lines = latex_body.splitlines()
311+
292312
if is_split:
293-
rows[2].replace("affected_cols", "3")
313+
header = rf"""
314+
\begin{{tabular}}{{@{{\extracolsep{{6pt}}}}llllllll@{{}}}}
315+
\toprule
316+
\multicolumn{{2}}{{l}}{{Feature}} &
317+
\multicolumn{{3}}{{l}}{{Affected}} &
318+
\multicolumn{{2}}{{l}}{{{risk_name} Risk (${int(conf_level*100)}\%$ CI)}} &
319+
{risk_name} Risk Difference (${int(conf_level*100)}\%$ CI) \\
320+
\cmidrule(lr){{1-2}}\cmidrule(lr){{3-5}}\cmidrule(lr){{6-7}}\cmidrule(lr){{8-8}}
321+
& & n & \% & t/v & n & \% & \\ \midrule
322+
"""
294323
else:
295-
rows[2].replace("affected_cols", "2")
296-
ret_lines = "\n".join(rows)
324+
header = rf"""
325+
\begin{{tabular}}{{@{{\extracolsep{{6pt}}}}lllllll@{{}}}}
326+
\toprule
327+
\multicolumn{{2}}{{l}}{{Feature}} &
328+
\multicolumn{{2}}{{l}}{{Affected}} &
329+
\multicolumn{{2}}{{l}}{{{risk_name} Risk (${int(conf_level*100)}\%$ CI)}} &
330+
{risk_name} Risk Difference (${int(conf_level*100)}\%$ CI) \\
331+
\cmidrule(lr){{1-2}}\cmidrule(lr){{3-4}}\cmidrule(lr){{5-6}}\cmidrule(lr){{7-7}}
332+
& & n & \% & n & \% & \\ \midrule
333+
"""
334+
# remove extra leading spaces
335+
header = textwrap.dedent(header).strip()
336+
337+
body = "\n".join(_latex_escape(line) for line in body_lines[5:-1])
338+
339+
ret_lines = header + "\n" + body + "\n\\end{tabular}"
297340
if save_path is not None:
298341
save_path.with_suffix(".tex").write_text(ret_lines)
299342
return ret_lines
@@ -303,3 +346,5 @@ def make_risk_report(
303346
return df_risk_table.to_html(index=False)
304347
elif return_type.lower() == "dict":
305348
return ret_dict
349+
else:
350+
raise ValueError(f"Unsupported return_type {repr(return_type)}")

diff_binom_confint/_specials/_wang.py

Lines changed: 34 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -43,16 +43,17 @@ def wang_binomial_ci(
4343
sides : Union[str, int], optional
4444
sides: str or int, default "two-sided",
4545
the sides of the confidence interval, should be one of
46-
"two-sided" (aliases "2-sided", "two_sided", "2_sided", "2-sides", "two_sides", "two-sides", "2_sides", "ts", "t", "two", "2", 2),
46+
"two-sided" (aliases "2-sided", "two_sided", "2_sided", "2-sides",
47+
"two_sides", "two-sides", "2_sides", "ts", "t", "two", "2", 2),
4748
"left-sided" (aliases "left_sided", "left", "ls", "l"),
4849
"right-sided" (aliases "right_sided", "right", "rs", "r"),
4950
case insensitive.
5051
precision : float, optional
51-
Precision for the search algorithm, by default 1e-5
52+
Precision for the search algorithm, by default 1e-5.
5253
grid_one : int, optional
53-
Number of grid points in first step, by default 30
54+
Number of grid points in first step, by default 30.
5455
grid_two : int, optional
55-
Number of grid points in second step, by default 20
56+
Number of grid points in second step, by default 20.
5657
verbose : bool, optional
5758
Verbosity for debug message.
5859
@@ -101,7 +102,6 @@ def wang_binomial_ci(
101102
precision,
102103
grid_one,
103104
grid_two,
104-
verbose,
105105
)
106106
if verbose:
107107
print(f"Left CI: {ci_l}")
@@ -115,7 +115,6 @@ def wang_binomial_ci(
115115
precision,
116116
grid_one,
117117
grid_two,
118-
verbose,
119118
)
120119
if verbose:
121120
print(f"Right CI: {ci_u}")
@@ -124,7 +123,7 @@ def wang_binomial_ci(
124123
return ConfidenceInterval(lower, upper, estimate, conf_level, "wang", sides_val)
125124
else:
126125
ci = binomial_ci_one_sided(
127-
n_positive, n_total, ref_positive, ref_total, conf_level, sides_val, precision, grid_one, grid_two, verbose
126+
n_positive, n_total, ref_positive, ref_total, conf_level, sides_val, precision, grid_one, grid_two
128127
)
129128
return ConfidenceInterval(ci[1], ci[2], ci[0], conf_level, "wang", sides_val)
130129

@@ -139,7 +138,6 @@ def binomial_ci_one_sided(
139138
precision: float,
140139
grid_one: int,
141140
grid_two: int,
142-
verbose: bool = False,
143141
) -> List[float]:
144142
"""Helper function that calculates one-sided confidence interval.
145143
@@ -154,11 +152,12 @@ def binomial_ci_one_sided(
154152
ref_total : int
155153
total number of samples of the reference.
156154
conf_level : float, optional
157-
Confidence level, by default 0.95
155+
Confidence level, by default 0.95.
158156
sides : Union[str, int], optional
159157
sides: str or int, default "two-sided",
160158
the sides of the confidence interval, should be one of
161-
"two-sided" (aliases "2-sided", "two_sided", "2_sided", "2-sides", "two_sides", "two-sides", "2_sides", "ts", "t", "two", "2", 2),
159+
"two-sided" (aliases "2-sided", "two_sided", "2_sided", "2-sides",
160+
"two_sides", "two-sides", "2_sides", "ts", "t", "two", "2", 2),
162161
"left-sided" (aliases "left_sided", "left", "ls", "l"),
163162
"right-sided" (aliases "right_sided", "right", "rs", "r"),
164163
case insensitive.
@@ -168,8 +167,6 @@ def binomial_ci_one_sided(
168167
Number of grid points in first step, by default 30.
169168
grid_two : int, optional
170169
Number of grid points in second step, by default 20.
171-
verbose : bool, optional
172-
Verbosity for debug message, by default False.
173170
174171
Returns
175172
-------
@@ -209,7 +206,7 @@ def binomial_ci_one_sided(
209206
f[:, 2] = (p1hat - p0hat) / np.sqrt(denom)
210207

211208
# Sort f by the third column in descending order
212-
f = f[(-f[:, 2]).argsort(kind="stable"), :]
209+
f = f[(-f[:, 2]).argsort(), :]
213210

214211
allvector = np.round(f[:, 0] * (m + 2) + f[:, 1]).astype(int)
215212
allvectormove = np.round((f[:, 0] + 1) * (m + 3) + (f[:, 1] + 1)).astype(int)
@@ -273,7 +270,7 @@ def binomial_ci_one_sided(
273270

274271
# Generate N
275272
n_arr = np.unique(np.vstack((a, b)), axis=0)
276-
nvector = ((n_arr[:, 0] + 1) * (m + 3) + n_arr[:, 1] + 1).astype(int) # type: ignore
273+
nvector = ((n_arr[:, 0] + 1) * (m + 3) + n_arr[:, 1] + 1).astype(int)
277274
nvector = nvector[np.isin(nvector, allvectormove)]
278275

279276
skvector = ((s[:kk, 0] + 1) * (m + 3) + s[:kk, 1] + 1).astype(int)
@@ -308,7 +305,6 @@ def binomial_ci_one_sided(
308305
else:
309306
length_nc = nc_arr.shape[0]
310307

311-
ncmax = 0 # avoid pylance warning
312308
for ci in range(length_nc):
313309
ls_arr[kk, 0:2] = nc_arr[ci, 0:2]
314310
i1_vec = ls_arr[: (kk + 1), 0]
@@ -369,7 +365,7 @@ def binomial_ci_one_sided(
369365
if length_nc >= 2:
370366
valid = ~np.isnan(nc_arr[:, 0])
371367
ncnomiss = nc_arr[valid]
372-
ncnomiss = ncnomiss[(-ncnomiss[:, 2]).argsort(kind="stable"), :]
368+
ncnomiss = ncnomiss[(-ncnomiss[:, 2]).argsort(), :]
373369
morepoint = np.sum(ncnomiss[:, 2] >= ncnomiss[0, 2] - delta)
374370
if morepoint >= 2:
375371
ls_arr[kk : kk + morepoint, 0:2] = ncnomiss[:morepoint, 0:2]
@@ -435,8 +431,7 @@ def binomial_ci_one_sided(
435431

436432
kk1 = kk
437433

438-
# output = [val.item() if isinstance(val, np.generic) else val for val in output]
439-
output = np.array(output).tolist()
434+
output = [val.item() if isinstance(val, np.generic) else val for val in output]
440435

441436
return output
442437

@@ -463,32 +458,27 @@ def _prob2step(delv, delta, n, m, i1, i2, grid_one, grid_two):
463458
p0 = np.linspace(-delv + delta, 1 - delta, grid_one)
464459
else:
465460
p0 = np.linspace(delta, 1 - delv - delta, grid_one)
466-
467461
i1 = np.atleast_1d(i1)
468462
i2 = np.atleast_1d(i2)
469463
part1 = np.log(comb(n, i1))[:, None] + np.outer(i1, np.log(p0 + delv)) + np.outer(n - i1, np.log(1 - p0 - delv))
470464
part2 = np.log(comb(m, i2))[:, None] + np.outer(i2, np.log(p0)) + np.outer(m - i2, np.log(1 - p0))
471465
sumofprob = np.exp(part1 + part2).sum(axis=0)
472466

473-
# mansum = sumofprob.max()
474-
# atol = 1e-14 * (mansum if mansum > 0 else 1.0)
475-
# plateau_idx = np.where(np.isclose(sumofprob, mansum, rtol=0.0, atol=atol))[0]
476-
plateau_idx = np.where(sumofprob == sumofprob.max())[0]
467+
# plateau-aware refinement (R: which(sumofprob == max(sumofprob)))
468+
mansum = sumofprob.max()
469+
atol = 1e-14 * (mansum if mansum > 0 else 1.0)
470+
plateau_idx = np.where(np.isclose(sumofprob, mansum, rtol=0.0, atol=atol))[0]
477471
leftmost = plateau_idx.min()
478472
rightmost = plateau_idx.max()
479473

480-
denom = (grid_one - 1) if grid_one > 1 else 1
481-
stepv = (p0[-1] - p0[0]) / denom
482-
# lowerb = max(p0[0], p0[rightmost] - stepv) + delta
483-
# upperb = min(p0[-1], p0[leftmost] + stepv) - delta
474+
stepv = (p0[-1] - p0[0]) / grid_one
475+
lowerb = max(p0[0], p0[rightmost] - stepv) + delta
476+
upperb = min(p0[-1], p0[leftmost] + stepv) - delta
484477

485-
raw_lowerb = max(p0[0], p0[rightmost] - stepv) + delta
486-
raw_upperb = min(p0[-1], p0[leftmost] + stepv) - delta
487-
if raw_lowerb <= raw_upperb:
488-
lowerb, upperb = raw_lowerb, raw_upperb
489-
else:
490-
# Ensure bounds are ordered for linspace; swap if necessary
491-
lowerb, upperb = raw_upperb, raw_lowerb
478+
# stepv = (p0[-1] - p0[0]) / grid_one
479+
# maxloc = np.argmax(sumofprob)
480+
# lowerb = max(p0[0], p0[maxloc] - stepv) + delta
481+
# upperb = min(p0[-1], p0[maxloc] + stepv) - delta
492482

493483
p0 = np.linspace(lowerb, upperb, grid_two)
494484
part1 = np.log(comb(n, i1))[:, None] + np.outer(i1, np.log(p0 + delv)) + np.outer(n - i1, np.log(1 - p0 - delv))
@@ -502,25 +492,28 @@ def _prob2steplmin(delv, delta, n, m, i1, i2, grid_one, grid_two):
502492
p0 = np.linspace(-delv + delta, 1 - delta, grid_one)
503493
else:
504494
p0 = np.linspace(delta, 1 - delv - delta, grid_one)
505-
506495
i1 = np.atleast_1d(i1)
507496
i2 = np.atleast_1d(i2)
508497
part1 = np.log(comb(n, i1))[:, None] + np.outer(i1, np.log(p0 + delv)) + np.outer(n - i1, np.log(1 - p0 - delv))
509498
part2 = np.log(comb(m, i2))[:, None] + np.outer(i2, np.log(p0)) + np.outer(m - i2, np.log(1 - p0))
510499
sumofprob = np.exp(part1 + part2).sum(axis=0)
511500

512-
# mansum = sumofprob.min()
513-
# atol = 1e-14 * (abs(mansum) if mansum != 0 else 1.0)
514-
# plateau_idx = np.where(np.isclose(sumofprob, mansum, rtol=0.0, atol=atol))[0]
515-
plateau_idx = np.where(sumofprob == sumofprob.min())[0]
501+
# plateau-aware refinement for minima (R: which(sumofprob == min(sumofprob)))
502+
mansum = sumofprob.min()
503+
atol = 1e-14 * (abs(mansum) if mansum != 0 else 1.0)
504+
plateau_idx = np.where(np.isclose(sumofprob, mansum, rtol=0.0, atol=atol))[0]
516505
leftmost = plateau_idx.min()
517506
rightmost = plateau_idx.max()
518507

519-
denom = (grid_one - 1) if grid_one > 1 else 1
520-
stepv = (p0[-1] - p0[0]) / denom
508+
stepv = (p0[-1] - p0[0]) / grid_one
521509
lowerb = max(p0[0], p0[rightmost] - stepv) + delta
522510
upperb = min(p0[-1], p0[leftmost] + stepv) - delta
523511

512+
# stepv = (p0[-1] - p0[0]) / grid_one
513+
# minloc = np.argmin(sumofprob)
514+
# lowerb = max(p0[0], p0[minloc] - stepv) + delta
515+
# upperb = min(p0[-1], p0[minloc] + stepv) - delta
516+
524517
p0 = np.linspace(lowerb, upperb, grid_two)
525518
part1 = np.log(comb(n, i1))[:, None] + np.outer(i1, np.log(p0 + delv)) + np.outer(n - i1, np.log(1 - p0 - delv))
526519
part2 = np.log(comb(m, i2))[:, None] + np.outer(i2, np.log(p0)) + np.outer(m - i2, np.log(1 - p0))

test/test_applications.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,10 @@ def test_make_risk_report():
3131
[str(_TMP_DIR / "risk-report"), None], # save_path
3232
)
3333

34-
for data_source, ref_classes, risk_name, return_type, save_path in grid:
34+
for data_source, rc, risk_name, return_type, save_path in grid:
3535
report = make_risk_report(
3636
data_source=data_source,
37-
ref_classes=ref_classes,
37+
ref_classes=rc,
3838
risk_name=risk_name,
3939
return_type=return_type,
4040
save_path=save_path,
@@ -49,6 +49,16 @@ def test_make_risk_report():
4949
elif return_type in ("latex", "md", "markdown", "html"):
5050
assert isinstance(report, str)
5151

52+
with pytest.raises(ValueError, match="Unsupported return_type"):
53+
make_risk_report(
54+
data_source=df_test,
55+
ref_classes=ref_classes,
56+
risk_name="Seizure",
57+
target="HasSeizure",
58+
positive_class="Yes",
59+
return_type="xxx",
60+
)
61+
5262
with pytest.raises(ValueError, match=f"target {repr('xxx')} not in the columns"):
5363
make_risk_report(
5464
data_source=df_test,

0 commit comments

Comments
 (0)