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.
Files changed (31) hide show
  1. medxfair-0.4.0/LICENSE +21 -0
  2. medxfair-0.4.0/PKG-INFO +50 -0
  3. medxfair-0.4.0/README.md +31 -0
  4. medxfair-0.4.0/pyproject.toml +21 -0
  5. medxfair-0.4.0/setup.cfg +4 -0
  6. medxfair-0.4.0/src/medxfair/__init__.py +12 -0
  7. medxfair-0.4.0/src/medxfair/config.py +36 -0
  8. medxfair-0.4.0/src/medxfair/core.py +45 -0
  9. medxfair-0.4.0/src/medxfair/data_audit.py +25 -0
  10. medxfair-0.4.0/src/medxfair/disparity_robust.py +49 -0
  11. medxfair-0.4.0/src/medxfair/exceptions.py +2 -0
  12. medxfair-0.4.0/src/medxfair/explainability.py +58 -0
  13. medxfair-0.4.0/src/medxfair/intersectional.py +39 -0
  14. medxfair-0.4.0/src/medxfair/missingness_robust.py +54 -0
  15. medxfair-0.4.0/src/medxfair/mitigation.py +48 -0
  16. medxfair-0.4.0/src/medxfair/reporting.py +58 -0
  17. medxfair-0.4.0/src/medxfair/sensitivity.py +47 -0
  18. medxfair-0.4.0/src/medxfair/statistics.py +14 -0
  19. medxfair-0.4.0/src/medxfair/utils.py +37 -0
  20. medxfair-0.4.0/src/medxfair/validation.py +19 -0
  21. medxfair-0.4.0/src/medxfair.egg-info/PKG-INFO +50 -0
  22. medxfair-0.4.0/src/medxfair.egg-info/SOURCES.txt +29 -0
  23. medxfair-0.4.0/src/medxfair.egg-info/dependency_links.txt +1 -0
  24. medxfair-0.4.0/src/medxfair.egg-info/requires.txt +12 -0
  25. medxfair-0.4.0/src/medxfair.egg-info/top_level.txt +1 -0
  26. medxfair-0.4.0/tests/test_disparity_robust.py +11 -0
  27. medxfair-0.4.0/tests/test_intersectional.py +13 -0
  28. medxfair-0.4.0/tests/test_missingness_robust.py +14 -0
  29. medxfair-0.4.0/tests/test_reporting.py +11 -0
  30. medxfair-0.4.0/tests/test_statistics.py +9 -0
  31. 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.
@@ -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,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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,2 @@
1
+ class AuditError(ValueError):
2
+ """Raised when audit configuration or data are invalid."""
@@ -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,12 @@
1
+ numpy>=1.23
2
+ pandas>=1.5
3
+ scipy>=1.9
4
+ scikit-learn>=1.2
5
+
6
+ [dev]
7
+ pytest>=7
8
+ build>=1
9
+ ruff>=0.5
10
+
11
+ [explain]
12
+ shap>=0.44
@@ -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]