adamstat 1.0.0__py3-none-any.whl

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.
adamstat/__init__.py ADDED
@@ -0,0 +1,162 @@
1
+ """
2
+ adamstat: CDISC ADaM, SDTM, Biostatistics, and Clinical Study Reporting Toolkit.
3
+
4
+ A comprehensive Python package for clinical trial data analytics, standardization,
5
+ biostatistical modeling, and ICH E3 compliant CSR generation.
6
+ """
7
+
8
+ __version__ = "1.0.0"
9
+ __author__ = "VSRAJHAVEL"
10
+
11
+ # Exploratory data analysis
12
+ from adamstat.eda import (
13
+ clinical_summary,
14
+ completeness_audit,
15
+ categorical_profiles,
16
+ column_structure,
17
+ )
18
+
19
+ # Clinical cleaning and harmonization
20
+ from adamstat.cleaning import (
21
+ standardize_demographics,
22
+ clean_biometric_outliers,
23
+ standardize_dates,
24
+ deduplicate_clinical,
25
+ impute_clinical_data,
26
+ ClinicalDataCleaner,
27
+ )
28
+
29
+ # Derived variables and classifications
30
+ from adamstat.derivation import (
31
+ derive_age_group,
32
+ derive_bmi_category,
33
+ derive_bp_category,
34
+ derive_days_on_study,
35
+ derive_high_risk_flag,
36
+ derive_clinical_features,
37
+ )
38
+
39
+ # Visualizations
40
+ from adamstat.visualization import (
41
+ plot_demographics,
42
+ plot_vitals_scatter,
43
+ plot_treatment_boxplots,
44
+ plot_correlation_matrix,
45
+ plot_risk_proportions,
46
+ )
47
+
48
+ # CDISC SDTM domains
49
+ from adamstat.sdtm import (
50
+ create_dm,
51
+ create_vs,
52
+ create_lb,
53
+ create_ae,
54
+ create_ex,
55
+ validate_sdtm_domain,
56
+ )
57
+
58
+ # CDISC ADaM datasets
59
+ from adamstat.adam import (
60
+ create_adsl,
61
+ create_advs,
62
+ create_adlb,
63
+ create_adae,
64
+ derive_bds_dataset,
65
+ validate_adam,
66
+ )
67
+
68
+ # Clinical Biostatistics and Hypothesis Testing
69
+ from adamstat.stats import (
70
+ summary_by_arm,
71
+ check_normality,
72
+ check_homogeneity,
73
+ two_sample_ttest,
74
+ oneway_anova,
75
+ posthoc_tukey,
76
+ ae_contingency_test,
77
+ run_clinical_inferential_pipeline,
78
+ )
79
+
80
+ # CSR Reporting (Tables, Figures, Narratives)
81
+ from adamstat.reporting import (
82
+ generate_table_14_1_1_demographics,
83
+ generate_table_14_2_1_efficacy,
84
+ generate_table_14_3_1_adverse_events,
85
+ plot_figure_14_2_1_boxplot,
86
+ plot_figure_14_2_2_forest,
87
+ generate_csr_narrative,
88
+ )
89
+
90
+ # Pipeline and Sample Data
91
+ from adamstat.pipeline import ClinicalStudyPipeline
92
+ from adamstat.sample_data import (
93
+ load_sample_demographics,
94
+ load_sample_vitals_labs,
95
+ load_sample_clinical_trial,
96
+ generate_synthetic_clinical_trial,
97
+ )
98
+
99
+ __all__ = [
100
+ "__version__",
101
+ # eda
102
+ "clinical_summary",
103
+ "completeness_audit",
104
+ "categorical_profiles",
105
+ "column_structure",
106
+ # cleaning
107
+ "standardize_demographics",
108
+ "clean_biometric_outliers",
109
+ "standardize_dates",
110
+ "deduplicate_clinical",
111
+ "impute_clinical_data",
112
+ "ClinicalDataCleaner",
113
+ # derivation
114
+ "derive_age_group",
115
+ "derive_bmi_category",
116
+ "derive_bp_category",
117
+ "derive_days_on_study",
118
+ "derive_high_risk_flag",
119
+ "derive_clinical_features",
120
+ # visualization
121
+ "plot_demographics",
122
+ "plot_vitals_scatter",
123
+ "plot_treatment_boxplots",
124
+ "plot_correlation_matrix",
125
+ "plot_risk_proportions",
126
+ # sdtm
127
+ "create_dm",
128
+ "create_vs",
129
+ "create_lb",
130
+ "create_ae",
131
+ "create_ex",
132
+ "validate_sdtm_domain",
133
+ # adam
134
+ "create_adsl",
135
+ "create_advs",
136
+ "create_adlb",
137
+ "create_adae",
138
+ "derive_bds_dataset",
139
+ "validate_adam",
140
+ # stats
141
+ "summary_by_arm",
142
+ "check_normality",
143
+ "check_homogeneity",
144
+ "two_sample_ttest",
145
+ "oneway_anova",
146
+ "posthoc_tukey",
147
+ "ae_contingency_test",
148
+ "run_clinical_inferential_pipeline",
149
+ # reporting
150
+ "generate_table_14_1_1_demographics",
151
+ "generate_table_14_2_1_efficacy",
152
+ "generate_table_14_3_1_adverse_events",
153
+ "plot_figure_14_2_1_boxplot",
154
+ "plot_figure_14_2_2_forest",
155
+ "generate_csr_narrative",
156
+ # pipeline & data
157
+ "ClinicalStudyPipeline",
158
+ "load_sample_demographics",
159
+ "load_sample_vitals_labs",
160
+ "load_sample_clinical_trial",
161
+ "generate_synthetic_clinical_trial",
162
+ ]
adamstat/adam.py ADDED
@@ -0,0 +1,280 @@
1
+ """
2
+ CDISC ADaM (Analysis Data Model) Generation and Validation.
3
+ Includes ADSL, BDS (ADVS, ADLB), and OCCDS (ADAE).
4
+ """
5
+
6
+ from typing import Callable, Dict, List, Optional
7
+ import numpy as np
8
+ import pandas as pd
9
+
10
+
11
+ def create_adsl(
12
+ dm: pd.DataFrame,
13
+ ex: Optional[pd.DataFrame] = None,
14
+ vs: Optional[pd.DataFrame] = None,
15
+ age_bins: Optional[List[float]] = None,
16
+ age_labels: Optional[List[str]] = None
17
+ ) -> pd.DataFrame:
18
+ """
19
+ Generate Subject-Level Analysis Dataset (ADSL) according to CDISC ADaM standards.
20
+
21
+ Includes population flags (SAFFL, ITTFL, EFFFL), treatment dates (TRTSDT, TRTEDT),
22
+ baseline vitals, age categorization, and clinical high risk flags.
23
+ """
24
+ adsl = dm.copy()
25
+
26
+ # 1. Baseline Vital Signs (VISITNUM == 1)
27
+ if vs is not None:
28
+ vs_clean = vs.copy()
29
+ if "VISITNUM" in vs_clean.columns and "VSTESTCD" in vs_clean.columns:
30
+ vs_base = (
31
+ vs_clean.loc[pd.to_numeric(vs_clean["VISITNUM"], errors="coerce").eq(1)]
32
+ .assign(VSRESN=lambda x: pd.to_numeric(x["VSRESN"], errors="coerce"))
33
+ .sort_values(["USUBJID", "VSTESTCD"])
34
+ .dropna(subset=["VSRESN"])
35
+ .drop_duplicates(["USUBJID", "VSTESTCD"])
36
+ )
37
+ sbp_base = vs_base.loc[vs_base["VSTESTCD"].eq("SYSBP"), ["USUBJID", "VSRESN"]].rename(columns={"VSRESN": "BASE_SBP"})
38
+ dbp_base = vs_base.loc[vs_base["VSTESTCD"].eq("DIABP"), ["USUBJID", "VSRESN"]].rename(columns={"VSRESN": "BASE_DBP"})
39
+ adsl = adsl.merge(sbp_base, on="USUBJID", how="left")
40
+ adsl = adsl.merge(dbp_base, on="USUBJID", how="left")
41
+
42
+ # 2. Exposure integration
43
+ if ex is not None:
44
+ ex_clean = ex.copy()
45
+ drug_col = "EXDRUG" if "EXDRUG" in ex_clean.columns else ("EXTRT" if "EXTRT" in ex_clean.columns else None)
46
+ cols_to_pick = ["USUBJID"]
47
+ for c in [drug_col, "EXDOSE", "EXUNIT", "EXFREQ", "EXROUTE"]:
48
+ if c and c in ex_clean.columns:
49
+ cols_to_pick.append(c)
50
+ dose = ex_clean.sort_values("USUBJID").drop_duplicates("USUBJID")[cols_to_pick]
51
+ adsl = adsl.merge(dose, on="USUBJID", how="left")
52
+
53
+ # 3. Age Group Derivation
54
+ if "AGE" in adsl.columns:
55
+ adsl["AGE"] = pd.to_numeric(adsl["AGE"], errors="coerce")
56
+ if age_bins is None:
57
+ age_bins = [17.0, 40.0, 60.0, np.inf]
58
+ if age_labels is None:
59
+ age_labels = ["18-40", "41-60", "61+"]
60
+ adsl["AGE_GROUP"] = pd.cut(adsl["AGE"], bins=age_bins, labels=age_labels, right=True).astype("object")
61
+
62
+ # 4. High Risk Flag
63
+ if "BASE_SBP" in adsl.columns and "AGE" in adsl.columns:
64
+ adsl["HIGH_RISK"] = np.where(
65
+ adsl["AGE"].ge(60) & adsl["BASE_SBP"].ge(140), "Y", "N"
66
+ )
67
+ elif "AGE" in adsl.columns:
68
+ adsl["HIGH_RISK"] = np.where(adsl["AGE"].ge(65), "Y", "N")
69
+
70
+ # 5. CDISC Population Flags
71
+ adsl["SAFFL"] = "Y"
72
+ adsl["ITTFL"] = "Y"
73
+ adsl["EFFFL"] = "Y"
74
+
75
+ # 6. Treatment Start/End dates
76
+ if "RFSTDTC" in adsl.columns:
77
+ adsl["TRTSDT"] = pd.to_datetime(adsl["RFSTDTC"], errors="coerce").dt.strftime("%Y-%m-%d")
78
+ if "RFENDTC" in adsl.columns:
79
+ adsl["TRTEDT"] = pd.to_datetime(adsl["RFENDTC"], errors="coerce").dt.strftime("%Y-%m-%d")
80
+
81
+ adsl.insert(0, "ADSL", "ADSL")
82
+ return adsl
83
+
84
+
85
+ def derive_bds_dataset(
86
+ data: pd.DataFrame,
87
+ domain: str,
88
+ test_col: str,
89
+ value_col: str,
90
+ date_col: str,
91
+ response_rule: Optional[Callable[[pd.DataFrame], pd.Series]] = None,
92
+ adsl: Optional[pd.DataFrame] = None
93
+ ) -> pd.DataFrame:
94
+ """
95
+ Generate CDISC ADaM Basic Data Structure (BDS) analysis dataset.
96
+
97
+ Derives:
98
+ - AVAL: Analysis Value (numeric)
99
+ - PARAMCD & PARAM: Parameter Code and Description
100
+ - AVISIT & AVISITN: Analysis Visit Name and Number
101
+ - ADT: Analysis Date
102
+ - BASE: Baseline observation (AVISITN == 1)
103
+ - CHG: Change from baseline (AVAL - BASE)
104
+ - PCHG: Percent change from baseline ((CHG / BASE) * 100)
105
+ - ANL01FL: Analysis record flag (Baseline record)
106
+ - RESPONSE: Responder flag ('Y'/'N') based on clinical threshold rule
107
+ """
108
+ out = data.copy()
109
+ out["AVAL"] = pd.to_numeric(out[value_col], errors="coerce")
110
+ out["PARAMCD"] = out[test_col].astype("string")
111
+
112
+ name_col = "VSTEST" if (domain == "ADVS" and "VSTEST" in out.columns) else (
113
+ "LBTEST" if "LBTEST" in out.columns else test_col
114
+ )
115
+ out["PARAM"] = out[name_col].astype("string")
116
+
117
+ vis_col = "VISIT" if "VISIT" in out.columns else "AVISIT"
118
+ out["AVISIT"] = out[vis_col].astype("string") if vis_col in out.columns else "Baseline"
119
+
120
+ visnum_col = "VISITNUM" if "VISITNUM" in out.columns else "AVISITN"
121
+ out["AVISITN"] = pd.to_numeric(out[visnum_col], errors="coerce").fillna(1) if visnum_col in out.columns else 1
122
+
123
+ out["ADT"] = pd.to_datetime(out[date_col], errors="coerce").dt.strftime("%Y-%m-%d") if date_col in out.columns else "2025-01-01"
124
+
125
+ out = out.sort_values(["USUBJID", "PARAMCD", "AVISITN"]).reset_index(drop=True)
126
+
127
+ # Baseline: AVISITN == 1
128
+ baseline = (
129
+ out.loc[out["AVISITN"].eq(1) & out["AVAL"].notna(), ["USUBJID", "PARAMCD", "AVAL"]]
130
+ .drop_duplicates(["USUBJID", "PARAMCD"])
131
+ .rename(columns={"AVAL": "BASE"})
132
+ )
133
+ out = out.merge(baseline, on=["USUBJID", "PARAMCD"], how="left")
134
+
135
+ out["CHG"] = out["AVAL"] - out["BASE"]
136
+ out["PCHG"] = np.nan
137
+ valid_base = out["BASE"].notna() & out["BASE"].ne(0) & out["CHG"].notna()
138
+ out.loc[valid_base, "PCHG"] = (out.loc[valid_base, "CHG"] / out.loc[valid_base, "BASE"]) * 100
139
+
140
+ out["ANL01FL"] = np.where(out["AVISITN"].eq(1) & out["AVAL"].notna(), "Y", "")
141
+
142
+ # Responder derivation
143
+ valid_eval = out["AVAL"].notna() & out["BASE"].notna()
144
+ out["RESPONSE"] = pd.Series(np.nan, index=out.index, dtype="object")
145
+ out.loc[valid_eval, "RESPONSE"] = "N"
146
+
147
+ if response_rule is not None:
148
+ try:
149
+ meets_rule = response_rule(out)
150
+ out.loc[meets_rule, "RESPONSE"] = "Y"
151
+ except Exception:
152
+ pass
153
+
154
+ out.insert(0, "ADaM", domain)
155
+
156
+ # Merge ADSL demographic and treatment covariates if provided
157
+ if adsl is not None:
158
+ covar_cols = [c for c in ["USUBJID", "ARM", "ARMCD", "AGE", "AGE_GROUP", "SEX", "SAFFL", "ITTFL", "EFFFL"] if c in adsl.columns]
159
+ out = out.merge(adsl[covar_cols].drop_duplicates("USUBJID"), on="USUBJID", how="left")
160
+
161
+ return out
162
+
163
+
164
+ def create_advs(
165
+ vs: pd.DataFrame,
166
+ adsl: Optional[pd.DataFrame] = None,
167
+ response_rule: Optional[Callable[[pd.DataFrame], pd.Series]] = None
168
+ ) -> pd.DataFrame:
169
+ """
170
+ Generate Vital Signs BDS Analysis Dataset (ADVS).
171
+ Default response rule: Systolic Blood Pressure reduction >= 10 mmHg (CHG <= -10).
172
+ """
173
+ if response_rule is None:
174
+ response_rule = lambda d: d["PARAMCD"].eq("SYSBP") & d["CHG"].le(-10)
175
+
176
+ val_col = "VSRESN" if "VSRESN" in vs.columns else "VALUE"
177
+ date_col = "VSSTDTC" if "VSSTDTC" in vs.columns else ("VISITDATE" if "VISITDATE" in vs.columns else None)
178
+ return derive_bds_dataset(
179
+ data=vs,
180
+ domain="ADVS",
181
+ test_col="VSTESTCD",
182
+ value_col=val_col,
183
+ date_col=date_col,
184
+ response_rule=response_rule,
185
+ adsl=adsl
186
+ )
187
+
188
+
189
+ def create_adlb(
190
+ lb: pd.DataFrame,
191
+ adsl: Optional[pd.DataFrame] = None,
192
+ response_rule: Optional[Callable[[pd.DataFrame], pd.Series]] = None
193
+ ) -> pd.DataFrame:
194
+ """
195
+ Generate Laboratory BDS Analysis Dataset (ADLB).
196
+ Default response rule: Glucose reduction >= 10 mg/dL (CHG <= -10).
197
+ """
198
+ if response_rule is None:
199
+ response_rule = lambda d: d["PARAMCD"].astype("string").str.upper().str.contains("GLUC", na=False) & d["CHG"].le(-10)
200
+
201
+ val_col = "LBRESN" if "LBRESN" in lb.columns else "VALUE"
202
+ date_col = "LBSTDTC" if "LBSTDTC" in lb.columns else ("VISITDATE" if "VISITDATE" in lb.columns else None)
203
+ return derive_bds_dataset(
204
+ data=lb,
205
+ domain="ADLB",
206
+ test_col="LBTESTCD",
207
+ value_col=val_col,
208
+ date_col=date_col,
209
+ response_rule=response_rule,
210
+ adsl=adsl
211
+ )
212
+
213
+
214
+ def create_adae(
215
+ ae: pd.DataFrame,
216
+ adsl: Optional[pd.DataFrame] = None
217
+ ) -> pd.DataFrame:
218
+ """
219
+ Generate Adverse Event OCCDS Analysis Dataset (ADAE).
220
+
221
+ Includes numeric severity grading (AESEVN), causality grading (AERELN),
222
+ sequence indices (ASEQ), and treatment emergent flags.
223
+ """
224
+ adae = ae.copy()
225
+
226
+ if adsl is not None:
227
+ adsl_cols = [c for c in ["USUBJID", "ARM", "ARMCD", "EXDRUG", "EXDOSE", "EXUNIT", "SAFFL", "ITTFL", "EFFFL"] if c in adsl.columns]
228
+ adae = adae.merge(adsl[adsl_cols].drop_duplicates("USUBJID"), on="USUBJID", how="left")
229
+
230
+ if "AESEV" in adae.columns:
231
+ sev_map = {"mild": 1, "moderate": 2, "severe": 3}
232
+ adae["AESEVN"] = adae["AESEV"].astype("string").str.strip().str.casefold().map(sev_map).astype("Int64")
233
+
234
+ if "AEREL" in adae.columns:
235
+ rel_map = {"related": 1, "possibly related": 1, "possibly-related": 1, "unrelated": 0, "not related": 0}
236
+ adae["AERELN"] = adae["AEREL"].astype("string").str.strip().str.casefold().map(rel_map).astype("Int64")
237
+
238
+ if "AESTDTC" in adae.columns:
239
+ adae["ASTDT"] = pd.to_datetime(adae["AESTDTC"], errors="coerce").dt.strftime("%Y-%m-%d")
240
+ if "AEENDTC" in adae.columns:
241
+ adae["AENDT"] = pd.to_datetime(adae["AEENDTC"], errors="coerce").dt.strftime("%Y-%m-%d")
242
+
243
+ sort_cols = ["USUBJID"]
244
+ if "AESTDTC" in adae.columns: sort_cols.append("AESTDTC")
245
+ if "AETERM" in adae.columns: sort_cols.append("AETERM")
246
+ adae = adae.sort_values(sort_cols).reset_index(drop=True)
247
+ adae["ASEQ"] = adae.groupby("USUBJID").cumcount() + 1
248
+ adae.insert(0, "ADaM", "ADAE")
249
+
250
+ return adae
251
+
252
+
253
+ def validate_adam(datasets: Dict[str, pd.DataFrame]) -> Dict[str, Dict[str, any]]:
254
+ """
255
+ Validate conformance of generated ADaM datasets.
256
+ """
257
+ report = {}
258
+ for name, df in datasets.items():
259
+ summary = {
260
+ "rows": len(df),
261
+ "columns": len(df.columns),
262
+ "missing_usubjid": int(df["USUBJID"].isna().sum()) if "USUBJID" in df.columns else -1,
263
+ "unique_subjects": int(df["USUBJID"].nunique()) if "USUBJID" in df.columns else 0,
264
+ "status": "PASS"
265
+ }
266
+
267
+ if name == "ADSL":
268
+ summary["one_row_per_subject"] = not df["USUBJID"].duplicated().any()
269
+ if not summary["one_row_per_subject"]:
270
+ summary["status"] = "FAIL"
271
+ elif name in ["ADVS", "ADLB"]:
272
+ if "BASE" in df.columns and "AVAL" in df.columns and "CHG" in df.columns:
273
+ valid_mask = df["BASE"].notna() & df["AVAL"].notna()
274
+ errors = ((df.loc[valid_mask, "CHG"] - (df.loc[valid_mask, "AVAL"] - df.loc[valid_mask, "BASE"])).abs() > 1e-8).sum()
275
+ summary["chg_errors"] = int(errors)
276
+ if errors > 0:
277
+ summary["status"] = "FAIL"
278
+ report[name] = summary
279
+
280
+ return report