medxfair 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- medxfair-0.4.0/LICENSE +21 -0
- medxfair-0.4.0/PKG-INFO +50 -0
- medxfair-0.4.0/README.md +31 -0
- medxfair-0.4.0/pyproject.toml +21 -0
- medxfair-0.4.0/setup.cfg +4 -0
- medxfair-0.4.0/src/medxfair/__init__.py +12 -0
- medxfair-0.4.0/src/medxfair/config.py +36 -0
- medxfair-0.4.0/src/medxfair/core.py +45 -0
- medxfair-0.4.0/src/medxfair/data_audit.py +25 -0
- medxfair-0.4.0/src/medxfair/disparity_robust.py +49 -0
- medxfair-0.4.0/src/medxfair/exceptions.py +2 -0
- medxfair-0.4.0/src/medxfair/explainability.py +58 -0
- medxfair-0.4.0/src/medxfair/intersectional.py +39 -0
- medxfair-0.4.0/src/medxfair/missingness_robust.py +54 -0
- medxfair-0.4.0/src/medxfair/mitigation.py +48 -0
- medxfair-0.4.0/src/medxfair/reporting.py +58 -0
- medxfair-0.4.0/src/medxfair/sensitivity.py +47 -0
- medxfair-0.4.0/src/medxfair/statistics.py +14 -0
- medxfair-0.4.0/src/medxfair/utils.py +37 -0
- medxfair-0.4.0/src/medxfair/validation.py +19 -0
- medxfair-0.4.0/src/medxfair.egg-info/PKG-INFO +50 -0
- medxfair-0.4.0/src/medxfair.egg-info/SOURCES.txt +29 -0
- medxfair-0.4.0/src/medxfair.egg-info/dependency_links.txt +1 -0
- medxfair-0.4.0/src/medxfair.egg-info/requires.txt +12 -0
- medxfair-0.4.0/src/medxfair.egg-info/top_level.txt +1 -0
- medxfair-0.4.0/tests/test_disparity_robust.py +11 -0
- medxfair-0.4.0/tests/test_intersectional.py +13 -0
- medxfair-0.4.0/tests/test_missingness_robust.py +14 -0
- medxfair-0.4.0/tests/test_reporting.py +11 -0
- medxfair-0.4.0/tests/test_statistics.py +9 -0
- medxfair-0.4.0/tests/test_validation.py +34 -0
medxfair-0.4.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MedxFair contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
medxfair-0.4.0/PKG-INFO
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: medxfair
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Diagnosis-first intersectional auditing of binary clinical predictions
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Dist: numpy>=1.23
|
|
9
|
+
Requires-Dist: pandas>=1.5
|
|
10
|
+
Requires-Dist: scipy>=1.9
|
|
11
|
+
Requires-Dist: scikit-learn>=1.2
|
|
12
|
+
Provides-Extra: explain
|
|
13
|
+
Requires-Dist: shap>=0.44; extra == "explain"
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
16
|
+
Requires-Dist: build>=1; extra == "dev"
|
|
17
|
+
Requires-Dist: ruff>=0.5; extra == "dev"
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# MedxFair-ROBUST (v0.4.0)
|
|
21
|
+
|
|
22
|
+
Research-oriented Python package for retrospective fairness auditing of binary prediction models, including intersectional subgroup analysis, descriptive SHAP explanations, input substitution sensitivity checks, model mitigation review, and partial-identification bounds under missing protected attributes.
|
|
23
|
+
|
|
24
|
+
**Research use only:** model disparities do not prove discrimination; feature substitutions are not causal interventions; mitigation eligibility is not clinical safety. Missing-membership identification bounds are not sampling confidence intervals.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
python -m pip install -e ".[dev]"
|
|
30
|
+
python -m pytest -q
|
|
31
|
+
python examples/quickstart.py
|
|
32
|
+
python -m build
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Quickstart
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
import pandas as pd
|
|
39
|
+
from medxfair import MedxFair, AuditConfig
|
|
40
|
+
df = pd.read_csv("examples/example_predictions.csv")
|
|
41
|
+
config = AuditConfig(outcome_col="readmitted_30d", probability_col="predicted_probability", protected_attributes=["race","payer","age_group"], min_group_size=5,min_positive_support=2,min_negative_support=2)
|
|
42
|
+
report = MedxFair(config).run(df)
|
|
43
|
+
report.export_json("outputs/audit.json")
|
|
44
|
+
report.export_csv("outputs")
|
|
45
|
+
report.export_html("outputs/report.html")
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The package accepts precomputed binary-outcome probabilities and does not retrain the model. Threshold mode uses p>=threshold; budget mode selects exactly ceil(budget*N) high-risk rows, ties broken by input order. Missing attributes remain explicit categories; supported-group thresholds guard headline worst-group estimates. Wilson confidence intervals are pointwise, not simultaneous.
|
|
49
|
+
|
|
50
|
+
For partial-identification APIs, see `ROBUST.md`; for explanation, sensitivity and candidate review modules see `MILESTONE2.md`. The examples are synthetic software tests, not clinical data. Aggregate outputs can still disclose small-group information. Review before sharing.
|
medxfair-0.4.0/README.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# MedxFair-ROBUST (v0.4.0)
|
|
2
|
+
|
|
3
|
+
Research-oriented Python package for retrospective fairness auditing of binary prediction models, including intersectional subgroup analysis, descriptive SHAP explanations, input substitution sensitivity checks, model mitigation review, and partial-identification bounds under missing protected attributes.
|
|
4
|
+
|
|
5
|
+
**Research use only:** model disparities do not prove discrimination; feature substitutions are not causal interventions; mitigation eligibility is not clinical safety. Missing-membership identification bounds are not sampling confidence intervals.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
python -m pip install -e ".[dev]"
|
|
11
|
+
python -m pytest -q
|
|
12
|
+
python examples/quickstart.py
|
|
13
|
+
python -m build
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Quickstart
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
import pandas as pd
|
|
20
|
+
from medxfair import MedxFair, AuditConfig
|
|
21
|
+
df = pd.read_csv("examples/example_predictions.csv")
|
|
22
|
+
config = AuditConfig(outcome_col="readmitted_30d", probability_col="predicted_probability", protected_attributes=["race","payer","age_group"], min_group_size=5,min_positive_support=2,min_negative_support=2)
|
|
23
|
+
report = MedxFair(config).run(df)
|
|
24
|
+
report.export_json("outputs/audit.json")
|
|
25
|
+
report.export_csv("outputs")
|
|
26
|
+
report.export_html("outputs/report.html")
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The package accepts precomputed binary-outcome probabilities and does not retrain the model. Threshold mode uses p>=threshold; budget mode selects exactly ceil(budget*N) high-risk rows, ties broken by input order. Missing attributes remain explicit categories; supported-group thresholds guard headline worst-group estimates. Wilson confidence intervals are pointwise, not simultaneous.
|
|
30
|
+
|
|
31
|
+
For partial-identification APIs, see `ROBUST.md`; for explanation, sensitivity and candidate review modules see `MILESTONE2.md`. The examples are synthetic software tests, not clinical data. Aggregate outputs can still disclose small-group information. Review before sharing.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "medxfair"
|
|
7
|
+
version = "0.4.0"
|
|
8
|
+
description = "Diagnosis-first intersectional auditing of binary clinical predictions"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = ["numpy>=1.23", "pandas>=1.5", "scipy>=1.9", "scikit-learn>=1.2"]
|
|
12
|
+
|
|
13
|
+
[project.optional-dependencies]
|
|
14
|
+
explain = ["shap>=0.44"]
|
|
15
|
+
dev = ["pytest>=7", "build>=1", "ruff>=0.5"]
|
|
16
|
+
|
|
17
|
+
[tool.setuptools.packages.find]
|
|
18
|
+
where = ["src"]
|
|
19
|
+
|
|
20
|
+
[tool.pytest.ini_options]
|
|
21
|
+
testpaths = ["tests"]
|
medxfair-0.4.0/setup.cfg
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""MedxFair: retrospective fairness auditing and optional diagnostic extensions."""
|
|
2
|
+
from .config import AuditConfig
|
|
3
|
+
from .core import MedxFair
|
|
4
|
+
from .reporting import AuditReport
|
|
5
|
+
from .explainability import explain_predictions, ExplanationResult
|
|
6
|
+
from .sensitivity import feature_substitution, SensitivityResult
|
|
7
|
+
from .mitigation import review_candidates, ReviewConstraints, MitigationReview
|
|
8
|
+
__version__ = "0.4.0"
|
|
9
|
+
__all__ = ["AuditConfig","MedxFair","AuditReport","explain_predictions","ExplanationResult","feature_substitution","SensitivityResult","review_candidates","ReviewConstraints","MitigationReview"]
|
|
10
|
+
|
|
11
|
+
from .missingness_robust import audit_missing_group_bounds, mask_observed_groups, MissingGroupBounds
|
|
12
|
+
from .disparity_robust import audit_pairwise_disparity_bounds, DisparityBounds
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Literal
|
|
3
|
+
from .exceptions import AuditError
|
|
4
|
+
|
|
5
|
+
@dataclass(frozen=True)
|
|
6
|
+
class AuditConfig:
|
|
7
|
+
outcome_col: str
|
|
8
|
+
probability_col: str
|
|
9
|
+
protected_attributes: tuple[str, ...] | list[str]
|
|
10
|
+
threshold: float = 0.5
|
|
11
|
+
evaluation_mode: Literal["threshold", "budget"] = "threshold"
|
|
12
|
+
review_budget: float | None = None
|
|
13
|
+
min_group_size: int = 30
|
|
14
|
+
min_positive_support: int = 10
|
|
15
|
+
min_negative_support: int = 10
|
|
16
|
+
max_intersection_depth: int | None = None
|
|
17
|
+
confidence_level: float = 0.95
|
|
18
|
+
patient_id_col: str | None = None
|
|
19
|
+
record_id_col: str | None = None
|
|
20
|
+
high_missingness_threshold: float = 0.2
|
|
21
|
+
|
|
22
|
+
def __post_init__(self):
|
|
23
|
+
attrs = tuple(self.protected_attributes)
|
|
24
|
+
if not attrs or len(set(attrs)) != len(attrs):
|
|
25
|
+
raise AuditError("protected_attributes must be a nonempty set of unique column names")
|
|
26
|
+
object.__setattr__(self, "protected_attributes", attrs)
|
|
27
|
+
if not (0 <= self.threshold <= 1): raise AuditError("threshold must be in [0,1]")
|
|
28
|
+
if self.evaluation_mode not in ("threshold", "budget"): raise AuditError("invalid evaluation_mode")
|
|
29
|
+
if self.evaluation_mode == "budget" and (self.review_budget is None or not 0 < self.review_budget <= 1):
|
|
30
|
+
raise AuditError("budget mode requires review_budget in (0,1]")
|
|
31
|
+
if self.min_group_size < 1 or self.min_positive_support < 1 or self.min_negative_support < 1:
|
|
32
|
+
raise AuditError("support thresholds must be positive")
|
|
33
|
+
if not 0 < self.confidence_level < 1: raise AuditError("confidence_level must be in (0,1)")
|
|
34
|
+
if self.max_intersection_depth is not None and not 1 <= self.max_intersection_depth <= len(attrs):
|
|
35
|
+
raise AuditError("invalid max_intersection_depth")
|
|
36
|
+
if not 0 <= self.high_missingness_threshold <= 1: raise AuditError("invalid high_missingness_threshold")
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from dataclasses import asdict
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pandas as pd
|
|
5
|
+
from .config import AuditConfig
|
|
6
|
+
from .data_audit import cohort, missingness, missingness_by_groups
|
|
7
|
+
from .intersectional import subgroups, disparities
|
|
8
|
+
from .reporting import AuditReport
|
|
9
|
+
from .utils import validate, predicted_flags
|
|
10
|
+
from .validation import metrics
|
|
11
|
+
|
|
12
|
+
class MedxFair:
|
|
13
|
+
"""Audit precomputed probabilities for a binary outcome; does not train a model."""
|
|
14
|
+
def __init__(self, config: AuditConfig): self.config=config
|
|
15
|
+
|
|
16
|
+
def run(self, df: pd.DataFrame) -> AuditReport:
|
|
17
|
+
"""Return an offline audit; does not modify input or retain patient-level rows."""
|
|
18
|
+
c=self.config
|
|
19
|
+
data=df.reset_index(drop=True).copy()
|
|
20
|
+
y,p=validate(data,c)
|
|
21
|
+
flags,mode_info=predicted_flags(p,c)
|
|
22
|
+
overall=metrics(y,p,flags)
|
|
23
|
+
group_table=subgroups(data,y,p,flags,c,overall)
|
|
24
|
+
miss=missingness(data)
|
|
25
|
+
warnings=[]
|
|
26
|
+
def warn(code,severity,affected,message):warnings.append({"code":code,"severity":severity,"affected":affected,"message":message})
|
|
27
|
+
if not (y.min()!=y.max()):warn("SINGLE_CLASS","warning",c.outcome_col,"AUROC undefined with only one observed class")
|
|
28
|
+
if not np.isfinite(overall["precision"]):warn("UNDEFINED_PRECISION","warning","overall","No predicted positives")
|
|
29
|
+
for _,row in miss.iterrows():
|
|
30
|
+
if row.missing_rate >= c.high_missingness_threshold and row.missing_count:
|
|
31
|
+
warn("HIGH_MISSINGNESS","warning",row.column,f"Missing rate {row.missing_rate:.1%}")
|
|
32
|
+
for a in c.protected_attributes:
|
|
33
|
+
if data[a].isna().any():warn("MISSING_PROTECTED","warning",a,"Missing values audited as explicit category")
|
|
34
|
+
if c.patient_id_col:
|
|
35
|
+
repeated=int(data[c.patient_id_col].duplicated(keep=False).sum())
|
|
36
|
+
if repeated:warn("REPEATED_PATIENT","warning",c.patient_id_col,f"{repeated} records have repeated patient identifiers; record-level uncertainty assumes independence")
|
|
37
|
+
if c.record_id_col and data[c.record_id_col].duplicated(keep=False).any():
|
|
38
|
+
warn("DUPLICATE_RECORD_ID","warning",c.record_id_col,"Duplicate record identifiers found")
|
|
39
|
+
for _,r in group_table.iterrows():
|
|
40
|
+
if r.support_status!="SUPPORTED":warn("SPARSE_OR_UNDEFINED_GROUP","warning",r.group,f"{r.support_status}: n={r.n}, positives={r.positives}, negatives={r.negatives}")
|
|
41
|
+
warn("NO_SPLIT_PROVENANCE","info","evaluation","Prediction-only input cannot establish patient-disjoint train/test provenance")
|
|
42
|
+
warn("MULTIPLE_COMPARISONS","info","subgroups","Wilson intervals are pointwise, not multiplicity-adjusted")
|
|
43
|
+
return AuditReport(metadata={"package_version":"0.4.0","generated_at":datetime.now(timezone.utc).isoformat(),"config":asdict(c),"evaluation_mode":c.evaluation_mode,**mode_info},
|
|
44
|
+
cohort_summary=cohort(data,y,c),missingness=miss,missingness_by_group=missingness_by_groups(data,c),
|
|
45
|
+
overall_metrics=overall,subgroup_metrics=group_table,disparity_summary=disparities(group_table),warnings=warnings)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
from .utils import normalized_groups
|
|
3
|
+
|
|
4
|
+
def cohort(df, y, config):
|
|
5
|
+
d = {"records":len(df),"positive_outcomes":int(y.sum()),"prevalence":float(y.mean())}
|
|
6
|
+
if config.patient_id_col:
|
|
7
|
+
d["unique_patients"] = int(df[config.patient_id_col].nunique(dropna=True))
|
|
8
|
+
d["repeated_patient_records"] = int(df[config.patient_id_col].duplicated(keep=False).sum())
|
|
9
|
+
if config.record_id_col:
|
|
10
|
+
d["duplicate_record_ids"] = int(df[config.record_id_col].duplicated(keep=False).sum())
|
|
11
|
+
return d
|
|
12
|
+
|
|
13
|
+
def missingness(df):
|
|
14
|
+
return pd.DataFrame([{"column":c,"missing_count":int(df[c].isna().sum()),"missing_rate":float(df[c].isna().mean()),"dtype":str(df[c].dtype)} for c in df.columns])
|
|
15
|
+
|
|
16
|
+
def missingness_by_groups(df, config):
|
|
17
|
+
rows=[]; attrs=config.protected_attributes
|
|
18
|
+
groups=normalized_groups(df,attrs)
|
|
19
|
+
for target in attrs:
|
|
20
|
+
for by in [x for x in attrs if x!=target]+[config.outcome_col]:
|
|
21
|
+
keys=groups[by] if by in groups else df[by].astype("string")
|
|
22
|
+
for label, idx in keys.groupby(keys,sort=True,dropna=False).groups.items():
|
|
23
|
+
subset=df.loc[idx,target]
|
|
24
|
+
rows.append({"target":target,"by":by,"group":str(label),"n":len(subset),"missing_rate":float(subset.isna().mean())})
|
|
25
|
+
return pd.DataFrame(rows,columns=["target","by","group","n","missing_rate"])
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
import numpy as np
|
|
3
|
+
import pandas as pd
|
|
4
|
+
|
|
5
|
+
@dataclass(frozen=True)
|
|
6
|
+
class DisparityBounds:
|
|
7
|
+
table: pd.DataFrame
|
|
8
|
+
metadata: dict
|
|
9
|
+
def export_csv(self, path): self.table.to_csv(path, index=False)
|
|
10
|
+
|
|
11
|
+
def audit_pairwise_disparity_bounds(data, *, group_col, outcome_col, prediction_col, pairs=None, threshold=0.5, max_missing_share=1.0, missing_values=None, tolerance=0.0):
|
|
12
|
+
"""Bounds for FNR_A-FNR_B and FPR_A-FPR_B with coupled unknown labels."""
|
|
13
|
+
if not 0 <= max_missing_share <= 1 or not 0 <= threshold <= 1 or tolerance < 0:
|
|
14
|
+
raise ValueError("Invalid threshold, allocation cap, or tolerance")
|
|
15
|
+
for col in (group_col,outcome_col,prediction_col):
|
|
16
|
+
if col not in data: raise ValueError(f"Missing column: {col}")
|
|
17
|
+
if data.empty: raise ValueError("Empty data")
|
|
18
|
+
y=pd.to_numeric(data[outcome_col],errors="coerce").to_numpy(dtype=float)
|
|
19
|
+
p=pd.to_numeric(data[prediction_col],errors="coerce").to_numpy(dtype=float)
|
|
20
|
+
if not np.isfinite(y).all() or not np.isin(y,[0,1]).all(): raise ValueError("Invalid outcomes")
|
|
21
|
+
if not np.isfinite(p).all() or ((p<0)|(p>1)).any(): raise ValueError("Invalid probabilities")
|
|
22
|
+
labels=data[group_col].astype("string")
|
|
23
|
+
missing=labels.isna()
|
|
24
|
+
if missing_values is not None: missing=missing|labels.isin([str(v) for v in missing_values]).fillna(False)
|
|
25
|
+
missing=missing.to_numpy(dtype=bool)
|
|
26
|
+
names=list(labels[~missing].dropna().unique())
|
|
27
|
+
if pairs is None: pairs=[(names[i],names[j]) for i in range(len(names)) for j in range(i+1,len(names))]
|
|
28
|
+
pred=p>=threshold
|
|
29
|
+
cells={"fn":(y==1)&~pred,"tp":(y==1)&pred,"fp":(y==0)&pred,"tn":(y==0)&~pred}
|
|
30
|
+
missing_counts={k:int((v&missing).sum()) for k,v in cells.items()}
|
|
31
|
+
counts={}
|
|
32
|
+
for g in names:
|
|
33
|
+
membership=labels.eq(g).fillna(False).to_numpy(dtype=bool)&~missing
|
|
34
|
+
counts[g]={k:int((membership&v).sum()) for k,v in cells.items()}
|
|
35
|
+
def ratio(a,b): return float(a/b) if b>0 else float("nan")
|
|
36
|
+
def bounds(a,b,metric):
|
|
37
|
+
num,den=("fn","tp") if metric=="fnr" else ("fp","tn")
|
|
38
|
+
an,ad=a[num],a[den];bn,bd=b[num],b[den]
|
|
39
|
+
mn=max_missing_share*missing_counts[num];md=max_missing_share*missing_counts[den]
|
|
40
|
+
return ratio(an,an+ad+md)-ratio(bn+mn,bn+bd+mn),ratio(an+mn,an+ad+mn)-ratio(bn,bn+bd+md),ratio(an,an+ad)-ratio(bn,bn+bd)
|
|
41
|
+
rows=[]
|
|
42
|
+
for a,b in pairs:
|
|
43
|
+
a,b=str(a),str(b)
|
|
44
|
+
if a==b or a not in counts or b not in counts: raise ValueError(f"Unknown pair {a}, {b}")
|
|
45
|
+
for metric in ("fnr","fpr"):
|
|
46
|
+
low,high,observed=bounds(counts[a],counts[b],metric)
|
|
47
|
+
direction="UNDEFINED" if not np.isfinite(low+high) else "A_HIGHER" if low>tolerance else "B_HIGHER" if high < -tolerance else "NOT_IDENTIFIED"
|
|
48
|
+
rows.append(dict(group_a=a,group_b=b,metric=metric,observed_difference=observed,difference_lower=low,difference_upper=high,direction=direction,group_a_observed_n=sum(counts[a].values()),group_b_observed_n=sum(counts[b].values()),max_missing_share=float(max_missing_share)))
|
|
49
|
+
return DisparityBounds(pd.DataFrame(rows),dict(n=len(data),missing_group_n=int(missing.sum()),missing_confusion_counts=missing_counts,threshold=threshold,max_missing_share=max_missing_share,tolerance=tolerance,assumptions="Observed labels fixed; unknown labels allocated to A, B or other",interpretation="Coupled partial-identification bounds; not confidence intervals",caveat="Caps below 1 are fractional relaxations and require justification"))
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Optional SHAP-based descriptive attribution audit; not causal inference."""
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pandas as pd
|
|
5
|
+
from .exceptions import AuditError
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class ExplanationResult:
|
|
9
|
+
global_importance: pd.DataFrame
|
|
10
|
+
subgroup_importance: pd.DataFrame
|
|
11
|
+
metadata: dict
|
|
12
|
+
|
|
13
|
+
def explain_predictions(predict_fn, features: pd.DataFrame, *, groups=None,
|
|
14
|
+
background_size=40, sample_size=80, seed=42, max_evals=None):
|
|
15
|
+
"""Compute probability-scale SHAP attribution for a callable returning P(Y=1).
|
|
16
|
+
|
|
17
|
+
Permutation SHAP with a background masker; only sampled rows are explained.
|
|
18
|
+
Requires pip install medxfair[explain]. Features must be numeric and finite.
|
|
19
|
+
No raw feature rows or per-patient explanations are returned.
|
|
20
|
+
"""
|
|
21
|
+
try:
|
|
22
|
+
import shap
|
|
23
|
+
except ImportError as exc:
|
|
24
|
+
raise ImportError("Install optional dependency: pip install medxfair[explain]") from exc
|
|
25
|
+
if not isinstance(features,pd.DataFrame) or features.empty or not features.columns.is_unique:
|
|
26
|
+
raise AuditError("features must be a nonempty DataFrame with unique column names")
|
|
27
|
+
x=features.reset_index(drop=True)
|
|
28
|
+
try: vals=x.to_numpy(dtype=float)
|
|
29
|
+
except (TypeError,ValueError) as exc: raise AuditError("SHAP feature matrix must be numeric") from exc
|
|
30
|
+
if not np.isfinite(vals).all(): raise AuditError("SHAP feature matrix must be finite")
|
|
31
|
+
if not callable(predict_fn): raise AuditError("predict_fn must be callable")
|
|
32
|
+
if background_size < 1 or sample_size < 1: raise AuditError("sample sizes must be positive")
|
|
33
|
+
rng=np.random.default_rng(seed)
|
|
34
|
+
bg_idx=np.sort(rng.choice(len(x),size=min(background_size,len(x)),replace=False))
|
|
35
|
+
sm_idx=np.sort(rng.choice(len(x),size=min(sample_size,len(x)),replace=False))
|
|
36
|
+
def wrapped(array):
|
|
37
|
+
inp=pd.DataFrame(np.asarray(array),columns=x.columns)
|
|
38
|
+
out=np.asarray(predict_fn(inp),dtype=float).reshape(-1)
|
|
39
|
+
if len(out)!=len(inp) or not np.isfinite(out).all() or np.any((out<0)|(out>1)):
|
|
40
|
+
raise AuditError("predict_fn must return finite probabilities in [0,1]")
|
|
41
|
+
return out
|
|
42
|
+
nfeatures=len(x.columns)
|
|
43
|
+
budget=max_evals if max_evals is not None else max(2*nfeatures+1, 30)
|
|
44
|
+
if budget < 2*nfeatures+1: raise AuditError("max_evals must be at least 2*n_features+1")
|
|
45
|
+
explainer=shap.Explainer(wrapped,shap.maskers.Independent(x.iloc[bg_idx]),algorithm="permutation",seed=seed)
|
|
46
|
+
values=np.asarray(explainer(x.iloc[sm_idx],max_evals=budget).values)
|
|
47
|
+
if values.shape!=(len(sm_idx),nfeatures): raise AuditError("Unexpected SHAP output dimensions")
|
|
48
|
+
importance=pd.DataFrame({"feature":x.columns,"mean_abs_shap":np.abs(values).mean(axis=0),"mean_shap":values.mean(axis=0)}).sort_values("mean_abs_shap",ascending=False).reset_index(drop=True)
|
|
49
|
+
group_rows=[]
|
|
50
|
+
if groups is not None:
|
|
51
|
+
g=pd.Series(groups).reset_index(drop=True)
|
|
52
|
+
if len(g)!=len(x): raise AuditError("groups must match feature row count")
|
|
53
|
+
for label in sorted(g.iloc[sm_idx].fillna("<MISSING>").astype(str).unique()):
|
|
54
|
+
mask=(g.iloc[sm_idx].fillna("<MISSING>").astype(str).to_numpy()==label)
|
|
55
|
+
for j,col in enumerate(x.columns):
|
|
56
|
+
group_rows.append({"group":label,"feature":col,"n_explained":int(mask.sum()),"mean_abs_shap":float(np.abs(values[mask,j]).mean()),"mean_shap":float(values[mask,j].mean())})
|
|
57
|
+
return ExplanationResult(importance,pd.DataFrame(group_rows,columns=["group","feature","n_explained","mean_abs_shap","mean_shap"]),
|
|
58
|
+
{"method":"permutation_SHAP","output_scale":"predicted_probability","background_n":len(bg_idx),"explained_n":len(sm_idx),"seed":seed,"warning":"Attributions depend on background, feature dependence, and model; not causal effects"})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from itertools import combinations
|
|
2
|
+
import numpy as np
|
|
3
|
+
import pandas as pd
|
|
4
|
+
from .statistics import wilson_interval
|
|
5
|
+
from .validation import metrics
|
|
6
|
+
from .utils import normalized_groups
|
|
7
|
+
|
|
8
|
+
PROPORTIONS = {"fnr":("fn","positives"),"fpr":("fp","negatives"),"sensitivity":("tp","positives"),"specificity":("tn","negatives"),"prevalence":("positives","n")}
|
|
9
|
+
|
|
10
|
+
def subgroups(df, y, p, flags, config, overall):
|
|
11
|
+
groups=normalized_groups(df,config.protected_attributes)
|
|
12
|
+
rows=[]; depth=config.max_intersection_depth or len(config.protected_attributes)
|
|
13
|
+
for k in range(1,depth+1):
|
|
14
|
+
for attrs in combinations(config.protected_attributes,k):
|
|
15
|
+
for values, indices in groups.groupby(list(attrs),dropna=False,sort=True).indices.items():
|
|
16
|
+
if k==1: values=(values,)
|
|
17
|
+
ix=np.asarray(indices,dtype=int)
|
|
18
|
+
m=metrics(y[ix],p[ix],flags[ix])
|
|
19
|
+
eligible = m["n"] >= config.min_group_size and m["positives"] >= config.min_positive_support and m["negatives"] >= config.min_negative_support
|
|
20
|
+
status = "UNDEFINED_METRIC" if m["positives"]==0 or m["negatives"]==0 else ("SUPPORTED" if eligible else "LOW_SUPPORT")
|
|
21
|
+
row={"depth":k,"attributes":" × ".join(attrs),"group":" | ".join(f"{a}={v}" for a,v in zip(attrs,values)),"support_status":status,**m}
|
|
22
|
+
for key,(num,den) in PROPORTIONS.items():
|
|
23
|
+
row[f"{key}_ci_low"],row[f"{key}_ci_high"]=wilson_interval(m[num],m[den],config.confidence_level)
|
|
24
|
+
for key in ("fnr","fpr","selection_rate","calibration_gap"):
|
|
25
|
+
row[f"{key}_difference"] = m[key]-overall[key]
|
|
26
|
+
rows.append(row)
|
|
27
|
+
return pd.DataFrame(rows)
|
|
28
|
+
|
|
29
|
+
def disparities(table):
|
|
30
|
+
rows=[]
|
|
31
|
+
for depth, t in table.groupby("depth"):
|
|
32
|
+
eligible=t[t.support_status=="SUPPORTED"]
|
|
33
|
+
for metric in ("fnr","fpr","selection_rate","calibration_gap"):
|
|
34
|
+
vals=eligible[metric].dropna()
|
|
35
|
+
if len(vals):
|
|
36
|
+
rows.append({"depth":int(depth),"metric":metric,"eligible_groups":len(vals),"worst_value":float(vals.max()),"between_group_gap":float(vals.max()-vals.min()),"worst_group":str(eligible.loc[vals.idxmax(),"group"])})
|
|
37
|
+
else:
|
|
38
|
+
rows.append({"depth":int(depth),"metric":metric,"eligible_groups":0,"worst_value":float("nan"),"between_group_gap":float("nan"),"worst_group":None})
|
|
39
|
+
return pd.DataFrame(rows)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Missing protected-group labels: partial-identification error-rate bounds."""
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pandas as pd
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class MissingGroupBounds:
|
|
8
|
+
table: pd.DataFrame
|
|
9
|
+
metadata: dict
|
|
10
|
+
def export_csv(self, path): self.table.to_csv(path,index=False)
|
|
11
|
+
|
|
12
|
+
def audit_missing_group_bounds(data, *, group_col, outcome_col, prediction_col, threshold=0.5, groups=None, missing_values=None):
|
|
13
|
+
for col in (group_col,outcome_col,prediction_col):
|
|
14
|
+
if col not in data: raise ValueError(f"Missing column: {col}")
|
|
15
|
+
if data.empty or not 0 <= threshold <= 1: raise ValueError("Invalid data/threshold")
|
|
16
|
+
y=pd.to_numeric(data[outcome_col],errors="coerce").to_numpy(dtype=float)
|
|
17
|
+
p=pd.to_numeric(data[prediction_col],errors="coerce").to_numpy(dtype=float)
|
|
18
|
+
if not np.isfinite(y).all() or not np.isin(y,[0,1]).all(): raise ValueError("Invalid outcome")
|
|
19
|
+
if not np.isfinite(p).all() or ((p<0)|(p>1)).any(): raise ValueError("Invalid predictions")
|
|
20
|
+
labels=data[group_col].astype("string")
|
|
21
|
+
missing=labels.isna()
|
|
22
|
+
if missing_values is not None: missing=missing|labels.isin([str(v) for v in missing_values]).fillna(False)
|
|
23
|
+
missing=missing.to_numpy(dtype=bool)
|
|
24
|
+
observed=labels[~missing]
|
|
25
|
+
names=list(observed.unique()) if groups is None else [str(g) for g in groups]
|
|
26
|
+
if len(names)<2 or len(set(names))!=len(names): raise ValueError("At least two unique groups required")
|
|
27
|
+
if not observed.isin(names).all(): raise ValueError("groups must contain observed categories")
|
|
28
|
+
pred=p>=threshold
|
|
29
|
+
cells=dict(fn=(y==1)&~pred,tp=(y==1)&pred,fp=(y==0)&pred,tn=(y==0)&~pred)
|
|
30
|
+
missing_counts={k:int((v&missing).sum()) for k,v in cells.items()}
|
|
31
|
+
def ratio(a,b): return float(a/b) if b else float("nan")
|
|
32
|
+
rows=[]
|
|
33
|
+
for g in names:
|
|
34
|
+
m=labels.eq(g).fillna(False).to_numpy(dtype=bool)&~missing
|
|
35
|
+
a,b,c,d=[int((m&cells[k]).sum()) for k in ("fn","tp","fp","tn")]
|
|
36
|
+
u,v,w,z=[missing_counts[k] for k in ("fn","tp","fp","tn")]
|
|
37
|
+
rows.append(dict(group=g,observed_n=int(m.sum()),observed_positives=a+b,observed_negatives=c+d,observed_fn=a,observed_tp=b,observed_fp=c,observed_tn=d,observed_fnr=ratio(a,a+b),fnr_lower=ratio(a,a+b+v),fnr_upper=ratio(a+u,a+b+u),observed_fpr=ratio(c,c+d),fpr_lower=ratio(c,c+d+z),fpr_upper=ratio(c+w,c+d+w),fnr_identified=bool(u==0 and v==0 and a+b>0),fpr_identified=bool(w==0 and z==0 and c+d>0)))
|
|
38
|
+
return MissingGroupBounds(pd.DataFrame(rows),dict(n_records=len(data),missing_group_n=int(missing.sum()),missing_group_fraction=float(missing.mean()),missing_confusion_counts=missing_counts,threshold=float(threshold),assumption="Unrestricted missing-group assignments",interpretation="Marginal identification bounds, not sampling confidence intervals",joint_attainability=False,outcome_and_predictions_fully_observed=True))
|
|
39
|
+
|
|
40
|
+
def mask_observed_groups(data, *, group_col, outcome_col, prediction_col, fraction, mechanism="MCAR",random_state=42):
|
|
41
|
+
if not 0<=fraction<1: raise ValueError("Invalid masking fraction")
|
|
42
|
+
if mechanism not in {"MCAR","OUTCOME_DEPENDENT","ERROR_DEPENDENT"}: raise ValueError("Invalid masking mechanism")
|
|
43
|
+
result=data.copy(deep=True)
|
|
44
|
+
observed=np.flatnonzero(result[group_col].notna().to_numpy())
|
|
45
|
+
n=int(round(len(observed)*fraction))
|
|
46
|
+
if not n:return result
|
|
47
|
+
rng=np.random.default_rng(random_state)
|
|
48
|
+
y=result[outcome_col].to_numpy()[observed];p=result[prediction_col].to_numpy()[observed]
|
|
49
|
+
weight=np.ones(len(observed))
|
|
50
|
+
if mechanism=="OUTCOME_DEPENDENT":weight=np.where(y==1,5.,1.)
|
|
51
|
+
if mechanism=="ERROR_DEPENDENT":weight=np.where((y==1)&(p<.5),7.,1.)
|
|
52
|
+
selected=rng.choice(observed,size=n,replace=False,p=weight/weight.sum())
|
|
53
|
+
result.iloc[selected,result.columns.get_loc(group_col)]=np.nan
|
|
54
|
+
return result
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from dataclasses import dataclass, asdict
|
|
2
|
+
from typing import Mapping
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pandas as pd
|
|
5
|
+
from .config import AuditConfig
|
|
6
|
+
from .core import MedxFair
|
|
7
|
+
from .exceptions import AuditError
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class ReviewConstraints:
|
|
11
|
+
max_auroc_drop: float = 0.01
|
|
12
|
+
max_brier_increase: float = 0.005
|
|
13
|
+
max_fnr_increase: float = 0.03
|
|
14
|
+
max_worst_group_fnr_increase: float = 0.03
|
|
15
|
+
min_worst_group_fnr_improvement: float = 0.0
|
|
16
|
+
def __post_init__(self):
|
|
17
|
+
if any(v < 0 or not np.isfinite(v) for v in asdict(self).values()):
|
|
18
|
+
raise AuditError('Constraints must be finite and nonnegative')
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class MitigationReview:
|
|
22
|
+
comparison: pd.DataFrame
|
|
23
|
+
metadata: dict
|
|
24
|
+
|
|
25
|
+
def review_candidates(data: pd.DataFrame, config: AuditConfig, candidate_probabilities: Mapping[str, object], *, constraints: ReviewConstraints = ReviewConstraints()):
|
|
26
|
+
if not isinstance(data, pd.DataFrame) or data.empty or not candidate_probabilities:
|
|
27
|
+
raise AuditError('Nonempty data and candidate probabilities required')
|
|
28
|
+
base = MedxFair(config).run(data)
|
|
29
|
+
def worst(report):
|
|
30
|
+
t = report.subgroup_metrics
|
|
31
|
+
t = t[(t.support_status == 'SUPPORTED') & np.isfinite(t.fnr)]
|
|
32
|
+
return float(t.fnr.max()) if len(t) else float('nan')
|
|
33
|
+
bm = base.overall_metrics; bw = worst(base)
|
|
34
|
+
rows = [dict(candidate='baseline', decision='BASELINE', reason='reference', auroc=bm['auroc'], brier=bm['brier'], fnr=bm['fnr'], worst_supported_fnr=bw, delta_auroc=0.,delta_brier=0.,delta_fnr=0.,delta_worst_supported_fnr=0.)]
|
|
35
|
+
for name, prob in candidate_probabilities.items():
|
|
36
|
+
if name == 'baseline': raise AuditError('baseline is reserved')
|
|
37
|
+
p = np.asarray(prob, dtype=float).reshape(-1)
|
|
38
|
+
if len(p) != len(data) or not np.isfinite(p).all() or ((p < 0) | (p > 1)).any(): raise AuditError('Invalid candidate')
|
|
39
|
+
tmp = data.copy(); tmp[config.probability_col] = p
|
|
40
|
+
mreport = MedxFair(config).run(tmp)
|
|
41
|
+
m = mreport.overall_metrics; w = worst(mreport)
|
|
42
|
+
delta = dict(auroc=m['auroc'] - bm['auroc'], brier=m['brier'] - bm['brier'], fnr=m['fnr'] - bm['fnr'], worst_supported_fnr=w-bw)
|
|
43
|
+
if not all(np.isfinite(v) for v in delta.values()): decision, reason = 'DEFER', 'Undefined evidence'
|
|
44
|
+
elif delta['auroc'] < -constraints.max_auroc_drop or delta['brier'] > constraints.max_brier_increase or delta['fnr'] > constraints.max_fnr_increase or delta['worst_supported_fnr'] > constraints.max_worst_group_fnr_increase: decision, reason = 'REJECT', 'Constraint violated'
|
|
45
|
+
elif delta['worst_supported_fnr'] < -constraints.min_worst_group_fnr_improvement: decision, reason = 'ELIGIBLE', 'Requires independent confirmation'
|
|
46
|
+
else: decision, reason = 'DEFER', 'No qualifying improvement'
|
|
47
|
+
rows.append(dict(candidate=str(name),decision=decision,reason=reason,auroc=m['auroc'],brier=m['brier'],fnr=m['fnr'],worst_supported_fnr=w,**{'delta_'+k:v for k,v in delta.items()}))
|
|
48
|
+
return MitigationReview(pd.DataFrame(rows), dict(constraints=asdict(constraints),evaluation_mode=config.evaluation_mode,selection_only=True,warning='Point-estimate rules are not clinical safety certificates'))
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from html import escape
|
|
4
|
+
import json
|
|
5
|
+
import math
|
|
6
|
+
import numpy as np
|
|
7
|
+
import pandas as pd
|
|
8
|
+
|
|
9
|
+
def clean(value):
|
|
10
|
+
if isinstance(value, dict): return {str(k):clean(v) for k,v in value.items()}
|
|
11
|
+
if isinstance(value,(list,tuple)): return [clean(v) for v in value]
|
|
12
|
+
if isinstance(value,np.generic): return clean(value.item())
|
|
13
|
+
if isinstance(value,(float,int)) and not isinstance(value,bool):
|
|
14
|
+
return None if not math.isfinite(value) else value
|
|
15
|
+
if pd.isna(value): return None
|
|
16
|
+
return value
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class AuditReport:
|
|
20
|
+
metadata: dict
|
|
21
|
+
cohort_summary: dict
|
|
22
|
+
missingness: pd.DataFrame
|
|
23
|
+
missingness_by_group: pd.DataFrame
|
|
24
|
+
overall_metrics: dict
|
|
25
|
+
subgroup_metrics: pd.DataFrame
|
|
26
|
+
disparity_summary: pd.DataFrame
|
|
27
|
+
warnings: list[dict]
|
|
28
|
+
|
|
29
|
+
def as_dict(self):
|
|
30
|
+
return clean({"metadata":self.metadata,"cohort_summary":self.cohort_summary,
|
|
31
|
+
"overall_metrics":self.overall_metrics,"missingness":self.missingness.to_dict("records"),
|
|
32
|
+
"missingness_by_group":self.missingness_by_group.to_dict("records"),
|
|
33
|
+
"subgroup_metrics":self.subgroup_metrics.to_dict("records"),
|
|
34
|
+
"disparity_summary":self.disparity_summary.to_dict("records"),"warnings":self.warnings})
|
|
35
|
+
|
|
36
|
+
def export_json(self,path):
|
|
37
|
+
path=Path(path);path.parent.mkdir(parents=True,exist_ok=True)
|
|
38
|
+
path.write_text(json.dumps(self.as_dict(),indent=2,allow_nan=False),encoding="utf-8")
|
|
39
|
+
|
|
40
|
+
def export_csv(self,directory):
|
|
41
|
+
d=Path(directory);d.mkdir(parents=True,exist_ok=True)
|
|
42
|
+
tables={"overall_metrics":pd.DataFrame([self.overall_metrics]),"subgroup_metrics":self.subgroup_metrics,
|
|
43
|
+
"missingness":self.missingness,"missingness_by_group":self.missingness_by_group,
|
|
44
|
+
"disparities":self.disparity_summary,"warnings":pd.DataFrame(self.warnings,columns=["code","severity","affected","message"])}
|
|
45
|
+
for name,frame in tables.items():frame.to_csv(d/f"{name}.csv",index=False)
|
|
46
|
+
|
|
47
|
+
def export_html(self,path):
|
|
48
|
+
path=Path(path);path.parent.mkdir(parents=True,exist_ok=True)
|
|
49
|
+
sections=[("Cohort",pd.DataFrame([self.cohort_summary])),("Overall metrics",pd.DataFrame([self.overall_metrics])),
|
|
50
|
+
("Missingness",self.missingness),("Missingness by group",self.missingness_by_group),
|
|
51
|
+
("Intersectional and marginal fairness",self.subgroup_metrics),("Disparity summary",self.disparity_summary),
|
|
52
|
+
("Warnings",pd.DataFrame(self.warnings))]
|
|
53
|
+
html=['<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>MedxFair Audit</title>',
|
|
54
|
+
'<style>body{font:15px system-ui,sans-serif;max-width:1150px;margin:40px auto;padding:0 18px;color:#17212b;background:#fafbfc}h1{color:#12364a}section{margin:28px 0}table{border-collapse:collapse;width:100%;font-size:12px}th,td{border-bottom:1px solid #ddd;padding:7px;text-align:left}th{background:#e8eff3;position:sticky;top:0}.scroll{overflow:auto;max-height:470px;background:white;border:1px solid #ddd}p{line-height:1.5}</style></head><body><h1>MedxFair — Audit Report</h1>',
|
|
55
|
+
'<p>Retrospective prediction audit. Differences do not establish discrimination or causality. This is not a clinical safety assessment. Sparse-group estimates may be unstable; intervals are unadjusted for multiplicity.</p>']
|
|
56
|
+
for title,frame in sections:
|
|
57
|
+
html.extend([f'<section><h2>{escape(title)}</h2><div class="scroll">',frame.to_html(index=False,escape=True,na_rep='—'),'</div></section>'])
|
|
58
|
+
html.append('</body></html>');path.write_text(''.join(html),encoding='utf-8')
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Non-causal feature-substitution sensitivity diagnostics."""
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pandas as pd
|
|
5
|
+
from .exceptions import AuditError
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class SensitivityResult:
|
|
9
|
+
summary: pd.DataFrame
|
|
10
|
+
by_group: pd.DataFrame
|
|
11
|
+
metadata: dict
|
|
12
|
+
|
|
13
|
+
def feature_substitution(predict_fn, features:pd.DataFrame, interventions:dict, *, groups=None):
|
|
14
|
+
"""Compare model probabilities after hypothetical feature substitutions.
|
|
15
|
+
|
|
16
|
+
Changes to feature values are not do-operator interventions, counterfactual
|
|
17
|
+
outcomes, or evidence of a causal pathway. Never modify the input dataframe.
|
|
18
|
+
"""
|
|
19
|
+
if not isinstance(features,pd.DataFrame) or features.empty or not callable(predict_fn):
|
|
20
|
+
raise AuditError("Provide nonempty features DataFrame and callable predict_fn")
|
|
21
|
+
if not interventions: raise AuditError("interventions cannot be empty")
|
|
22
|
+
x=features.reset_index(drop=True).copy(deep=True)
|
|
23
|
+
def predict(frame):
|
|
24
|
+
p=np.asarray(predict_fn(frame),dtype=float).reshape(-1)
|
|
25
|
+
if len(p)!=len(frame) or not np.isfinite(p).all() or np.any((p<0)|(p>1)):
|
|
26
|
+
raise AuditError("predict_fn must return finite probabilities in [0,1] for every row")
|
|
27
|
+
return p
|
|
28
|
+
base=predict(x)
|
|
29
|
+
g=None
|
|
30
|
+
if groups is not None:
|
|
31
|
+
g=pd.Series(groups).reset_index(drop=True).fillna("<MISSING>").astype(str)
|
|
32
|
+
if len(g)!=len(x):raise AuditError("groups length must match features")
|
|
33
|
+
summary=[];by_group=[]
|
|
34
|
+
for col,value in interventions.items():
|
|
35
|
+
if col not in x: raise AuditError(f"Unknown intervention feature: {col}")
|
|
36
|
+
changed=x.copy(deep=True)
|
|
37
|
+
if callable(value):raise AuditError("Callable interventions prohibited; provide a fixed scalar value")
|
|
38
|
+
if not np.isscalar(value):raise AuditError("Intervention values must be scalars")
|
|
39
|
+
changed[col]=value
|
|
40
|
+
delta=predict(changed)-base
|
|
41
|
+
summary.append({"feature":col,"substituted_value":str(value),"n":len(delta),"mean_probability_change":float(delta.mean()),"mean_abs_change":float(np.abs(delta).mean()),"min_change":float(delta.min()),"max_change":float(delta.max())})
|
|
42
|
+
if g is not None:
|
|
43
|
+
for label in sorted(g.unique()):
|
|
44
|
+
mask=(g.to_numpy()==label)
|
|
45
|
+
by_group.append({"feature":col,"group":label,"n":int(mask.sum()),"mean_probability_change":float(delta[mask].mean()),"mean_abs_change":float(np.abs(delta[mask]).mean())})
|
|
46
|
+
return SensitivityResult(pd.DataFrame(summary),pd.DataFrame(by_group,columns=["feature","group","n","mean_probability_change","mean_abs_change"]),
|
|
47
|
+
{"method":"feature_substitution","warning":"Associational model sensitivity only; not causal identification or a clinical intervention"})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from scipy.stats import norm
|
|
3
|
+
|
|
4
|
+
def safe_div(numerator, denominator):
|
|
5
|
+
return float(numerator / denominator) if denominator else float("nan")
|
|
6
|
+
|
|
7
|
+
def wilson_interval(successes: int, total: int, confidence: float = 0.95):
|
|
8
|
+
if total == 0: return (float("nan"), float("nan"))
|
|
9
|
+
z = norm.ppf((1 + confidence) / 2)
|
|
10
|
+
p = successes / total
|
|
11
|
+
den = 1 + z*z/total
|
|
12
|
+
center = (p + z*z/(2*total)) / den
|
|
13
|
+
radius = z / den * np.sqrt(p*(1-p)/total + z*z/(4*total*total))
|
|
14
|
+
return float(max(0, center-radius)), float(min(1, center+radius) if successes != total else 1.0)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pandas as pd
|
|
3
|
+
from .exceptions import AuditError
|
|
4
|
+
|
|
5
|
+
MISSING = "<MISSING>"
|
|
6
|
+
|
|
7
|
+
def normalized_groups(df, attributes):
|
|
8
|
+
out = df[list(attributes)].copy()
|
|
9
|
+
for col in attributes:
|
|
10
|
+
s = out[col].astype("string")
|
|
11
|
+
s = s.mask((s == MISSING).fillna(False), "<OBSERVED:MISSING>")
|
|
12
|
+
out[col] = s.fillna(MISSING)
|
|
13
|
+
return out
|
|
14
|
+
|
|
15
|
+
def validate(df, config):
|
|
16
|
+
if not isinstance(df, pd.DataFrame) or df.empty: raise AuditError("expected a nonempty pandas DataFrame")
|
|
17
|
+
required = [config.outcome_col, config.probability_col, *config.protected_attributes]
|
|
18
|
+
required += [x for x in (config.patient_id_col, config.record_id_col) if x]
|
|
19
|
+
if len(set(required)) != len(required): raise AuditError("configuration columns must be distinct")
|
|
20
|
+
absent = [x for x in required if x not in df.columns]
|
|
21
|
+
if absent: raise AuditError(f"missing columns: {absent}")
|
|
22
|
+
y = pd.to_numeric(df[config.outcome_col], errors="coerce")
|
|
23
|
+
if y.isna().any() or not y.isin([0,1]).all(): raise AuditError("outcome must be nonmissing binary 0/1")
|
|
24
|
+
p = pd.to_numeric(df[config.probability_col], errors="coerce").to_numpy(dtype=float)
|
|
25
|
+
if not np.isfinite(p).all() or np.any((p < 0) | (p > 1)):
|
|
26
|
+
raise AuditError("probabilities must be finite numeric values in [0,1]")
|
|
27
|
+
return y.to_numpy(dtype=int), p
|
|
28
|
+
|
|
29
|
+
def predicted_flags(probabilities, config):
|
|
30
|
+
n = len(probabilities)
|
|
31
|
+
if config.evaluation_mode == "threshold":
|
|
32
|
+
return (probabilities >= config.threshold).astype(int), {"threshold":config.threshold, "selected_count":int((probabilities >= config.threshold).sum())}
|
|
33
|
+
k = int(np.ceil(n * config.review_budget))
|
|
34
|
+
order = np.lexsort((np.arange(n), -probabilities))
|
|
35
|
+
flags = np.zeros(n, dtype=int)
|
|
36
|
+
flags[order[:k]] = 1
|
|
37
|
+
return flags, {"review_budget":config.review_budget,"selected_count":k,"cutoff_probability":float(probabilities[order[k-1]])}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from sklearn.metrics import roc_auc_score, average_precision_score, brier_score_loss, log_loss
|
|
3
|
+
from .statistics import safe_div
|
|
4
|
+
|
|
5
|
+
def metrics(y, p, flags):
|
|
6
|
+
y = np.asarray(y, dtype=int); p = np.asarray(p, dtype=float); flags = np.asarray(flags, dtype=int)
|
|
7
|
+
tp = int(((y==1)&(flags==1)).sum()); tn = int(((y==0)&(flags==0)).sum())
|
|
8
|
+
fp = int(((y==0)&(flags==1)).sum()); fn = int(((y==1)&(flags==0)).sum())
|
|
9
|
+
positives = tp+fn; negatives = tn+fp
|
|
10
|
+
result = {"n":len(y),"positives":positives,"negatives":negatives,"tp":tp,"tn":tn,"fp":fp,"fn":fn,
|
|
11
|
+
"prevalence":safe_div(positives,len(y)),"accuracy":safe_div(tp+tn,len(y)),
|
|
12
|
+
"precision":safe_div(tp,tp+fp),"recall":safe_div(tp,positives),"sensitivity":safe_div(tp,positives),
|
|
13
|
+
"specificity":safe_div(tn,negatives),"fnr":safe_div(fn,positives),"fpr":safe_div(fp,negatives),
|
|
14
|
+
"f1":safe_div(2*tp,2*tp+fp+fn),"selection_rate":float(flags.mean()),
|
|
15
|
+
"mean_probability":float(p.mean()),"calibration_gap":float(p.mean()-y.mean()),
|
|
16
|
+
"brier":float(brier_score_loss(y,p)),"log_loss":float(log_loss(y,np.clip(p,1e-15,1-1e-15),labels=[0,1]))}
|
|
17
|
+
result["auroc"] = float(roc_auc_score(y,p)) if positives and negatives else float("nan")
|
|
18
|
+
result["auprc"] = float(average_precision_score(y,p)) if positives else float("nan")
|
|
19
|
+
return result
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: medxfair
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Diagnosis-first intersectional auditing of binary clinical predictions
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Dist: numpy>=1.23
|
|
9
|
+
Requires-Dist: pandas>=1.5
|
|
10
|
+
Requires-Dist: scipy>=1.9
|
|
11
|
+
Requires-Dist: scikit-learn>=1.2
|
|
12
|
+
Provides-Extra: explain
|
|
13
|
+
Requires-Dist: shap>=0.44; extra == "explain"
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
16
|
+
Requires-Dist: build>=1; extra == "dev"
|
|
17
|
+
Requires-Dist: ruff>=0.5; extra == "dev"
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# MedxFair-ROBUST (v0.4.0)
|
|
21
|
+
|
|
22
|
+
Research-oriented Python package for retrospective fairness auditing of binary prediction models, including intersectional subgroup analysis, descriptive SHAP explanations, input substitution sensitivity checks, model mitigation review, and partial-identification bounds under missing protected attributes.
|
|
23
|
+
|
|
24
|
+
**Research use only:** model disparities do not prove discrimination; feature substitutions are not causal interventions; mitigation eligibility is not clinical safety. Missing-membership identification bounds are not sampling confidence intervals.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
python -m pip install -e ".[dev]"
|
|
30
|
+
python -m pytest -q
|
|
31
|
+
python examples/quickstart.py
|
|
32
|
+
python -m build
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Quickstart
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
import pandas as pd
|
|
39
|
+
from medxfair import MedxFair, AuditConfig
|
|
40
|
+
df = pd.read_csv("examples/example_predictions.csv")
|
|
41
|
+
config = AuditConfig(outcome_col="readmitted_30d", probability_col="predicted_probability", protected_attributes=["race","payer","age_group"], min_group_size=5,min_positive_support=2,min_negative_support=2)
|
|
42
|
+
report = MedxFair(config).run(df)
|
|
43
|
+
report.export_json("outputs/audit.json")
|
|
44
|
+
report.export_csv("outputs")
|
|
45
|
+
report.export_html("outputs/report.html")
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The package accepts precomputed binary-outcome probabilities and does not retrain the model. Threshold mode uses p>=threshold; budget mode selects exactly ceil(budget*N) high-risk rows, ties broken by input order. Missing attributes remain explicit categories; supported-group thresholds guard headline worst-group estimates. Wilson confidence intervals are pointwise, not simultaneous.
|
|
49
|
+
|
|
50
|
+
For partial-identification APIs, see `ROBUST.md`; for explanation, sensitivity and candidate review modules see `MILESTONE2.md`. The examples are synthetic software tests, not clinical data. Aggregate outputs can still disclose small-group information. Review before sharing.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/medxfair/__init__.py
|
|
5
|
+
src/medxfair/config.py
|
|
6
|
+
src/medxfair/core.py
|
|
7
|
+
src/medxfair/data_audit.py
|
|
8
|
+
src/medxfair/disparity_robust.py
|
|
9
|
+
src/medxfair/exceptions.py
|
|
10
|
+
src/medxfair/explainability.py
|
|
11
|
+
src/medxfair/intersectional.py
|
|
12
|
+
src/medxfair/missingness_robust.py
|
|
13
|
+
src/medxfair/mitigation.py
|
|
14
|
+
src/medxfair/reporting.py
|
|
15
|
+
src/medxfair/sensitivity.py
|
|
16
|
+
src/medxfair/statistics.py
|
|
17
|
+
src/medxfair/utils.py
|
|
18
|
+
src/medxfair/validation.py
|
|
19
|
+
src/medxfair.egg-info/PKG-INFO
|
|
20
|
+
src/medxfair.egg-info/SOURCES.txt
|
|
21
|
+
src/medxfair.egg-info/dependency_links.txt
|
|
22
|
+
src/medxfair.egg-info/requires.txt
|
|
23
|
+
src/medxfair.egg-info/top_level.txt
|
|
24
|
+
tests/test_disparity_robust.py
|
|
25
|
+
tests/test_intersectional.py
|
|
26
|
+
tests/test_missingness_robust.py
|
|
27
|
+
tests/test_reporting.py
|
|
28
|
+
tests/test_statistics.py
|
|
29
|
+
tests/test_validation.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
medxfair
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
import pytest
|
|
3
|
+
from medxfair import audit_pairwise_disparity_bounds
|
|
4
|
+
|
|
5
|
+
def test_pair_bounds():
|
|
6
|
+
d=pd.DataFrame(dict(group=["A","A","B","B",pd.NA,pd.NA],y=[1,1,1,1,1,1],p=[0,1,0,1,0,1]))
|
|
7
|
+
t=audit_pairwise_disparity_bounds(d,group_col="group",outcome_col="y",prediction_col="p",pairs=[("A","B")]).table
|
|
8
|
+
f=t[t.metric=="fnr"].iloc[0]
|
|
9
|
+
assert f.difference_lower==pytest.approx(-1/3)
|
|
10
|
+
assert f.difference_upper==pytest.approx(1/3)
|
|
11
|
+
assert f.direction=="NOT_IDENTIFIED"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from medxfair import MedxFair
|
|
2
|
+
|
|
3
|
+
def test_intersections(sample,config):
|
|
4
|
+
table=MedxFair(config).run(sample).subgroup_metrics
|
|
5
|
+
assert set(table.depth)=={1,2,3}
|
|
6
|
+
|
|
7
|
+
def test_missing_and_no_mutation(sample,config):
|
|
8
|
+
d=sample.copy()
|
|
9
|
+
d.loc[0,"payer"]=None
|
|
10
|
+
before=d.copy(deep=True)
|
|
11
|
+
report=MedxFair(config).run(d)
|
|
12
|
+
assert report.subgroup_metrics.group.str.contains("<MISSING>",regex=False).any()
|
|
13
|
+
assert d.equals(before)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
from medxfair import audit_missing_group_bounds,mask_observed_groups
|
|
3
|
+
|
|
4
|
+
def test_missing_labels():
|
|
5
|
+
df=pd.DataFrame(dict(g=["A","B",pd.NA,pd.NA],y=[1,1,1,1],p=[0,1,0,1]))
|
|
6
|
+
t=audit_missing_group_bounds(df,group_col="g",outcome_col="y",prediction_col="p").table
|
|
7
|
+
assert len(t)==2
|
|
8
|
+
assert t.iloc[0].fnr_lower <= t.iloc[0].observed_fnr <= t.iloc[0].fnr_upper
|
|
9
|
+
|
|
10
|
+
def test_mask_preserves_input():
|
|
11
|
+
df=pd.DataFrame(dict(g=["A","B","A","B"],y=[1,0,1,0],p=[0.,1.,0.,1.]))
|
|
12
|
+
m=mask_observed_groups(df,group_col="g",outcome_col="y",prediction_col="p",fraction=.5)
|
|
13
|
+
assert m.g.isna().sum()==2
|
|
14
|
+
assert df.g.notna().all()
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from medxfair import MedxFair
|
|
3
|
+
|
|
4
|
+
def test_exports(sample,config,tmp_path):
|
|
5
|
+
d=sample.copy();d["y"]=1
|
|
6
|
+
report=MedxFair(config).run(d)
|
|
7
|
+
report.export_json(tmp_path/"report.json")
|
|
8
|
+
report.export_csv(tmp_path/"csv")
|
|
9
|
+
report.export_html(tmp_path/"report.html")
|
|
10
|
+
assert json.loads((tmp_path/"report.json").read_text())["overall_metrics"]["auroc"] is None
|
|
11
|
+
assert (tmp_path/"csv"/"subgroup_metrics.csv").exists()
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import pytest
|
|
3
|
+
from medxfair.statistics import wilson_interval
|
|
4
|
+
|
|
5
|
+
def test_wilson():
|
|
6
|
+
assert all(math.isnan(x) for x in wilson_interval(0,0))
|
|
7
|
+
low,high=wilson_interval(5,10)
|
|
8
|
+
assert low==pytest.approx(.236593,abs=1e-5)
|
|
9
|
+
assert high==pytest.approx(.763407,abs=1e-5)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pytest
|
|
3
|
+
from sklearn.metrics import roc_auc_score,average_precision_score
|
|
4
|
+
from medxfair import MedxFair,AuditConfig
|
|
5
|
+
from medxfair.utils import predicted_flags
|
|
6
|
+
from medxfair.exceptions import AuditError
|
|
7
|
+
|
|
8
|
+
def test_confusion_and_auc(sample,config):
|
|
9
|
+
r=MedxFair(config).run(sample).overall_metrics
|
|
10
|
+
assert (r["tp"],r["tn"],r["fp"],r["fn"])==(2,2,2,2)
|
|
11
|
+
assert r["fnr"]==.5 and r["fpr"]==.5
|
|
12
|
+
assert r["auroc"]==pytest.approx(roc_auc_score(sample.y,sample.p))
|
|
13
|
+
assert r["auprc"]==pytest.approx(average_precision_score(sample.y,sample.p))
|
|
14
|
+
|
|
15
|
+
def test_one_class(sample,config):
|
|
16
|
+
d=sample.copy();d["y"]=1
|
|
17
|
+
r=MedxFair(config).run(d)
|
|
18
|
+
assert np.isnan(r.overall_metrics["auroc"])
|
|
19
|
+
assert any(w["code"]=="SINGLE_CLASS" for w in r.warnings)
|
|
20
|
+
|
|
21
|
+
def test_no_predicted_positives(sample,config):
|
|
22
|
+
d=sample.copy();d["p"]=0
|
|
23
|
+
r=MedxFair(config).run(d)
|
|
24
|
+
assert np.isnan(r.overall_metrics["precision"])
|
|
25
|
+
|
|
26
|
+
def test_invalid_probabilities(sample,config):
|
|
27
|
+
for value in [1.2,-.1,float("nan"),float("inf"),"oops"]:
|
|
28
|
+
d=sample.copy();d["p"]=d["p"].astype(object);d.loc[0,"p"]=value
|
|
29
|
+
with pytest.raises(AuditError):MedxFair(config).run(d)
|
|
30
|
+
|
|
31
|
+
def test_budget_ties():
|
|
32
|
+
c=AuditConfig("y","p",["race"],evaluation_mode="budget",review_budget=.25)
|
|
33
|
+
flags,_=predicted_flags(np.array([.5,.5,.5,.5,.1,.1,.1,.1]),c)
|
|
34
|
+
assert flags.tolist()==[1,1,0,0,0,0,0,0]
|