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 +162 -0
- adamstat/adam.py +280 -0
- adamstat/cleaning.py +302 -0
- adamstat/cli.py +122 -0
- adamstat/data/clinical_demographics.csv +181 -0
- adamstat/data/clinical_trial_data.csv +181 -0
- adamstat/data/clinical_vitals_labs.csv +181 -0
- adamstat/derivation.py +158 -0
- adamstat/eda.py +146 -0
- adamstat/experiments.py +479 -0
- adamstat/pipeline.py +113 -0
- adamstat/reporting.py +314 -0
- adamstat/sample_data.py +131 -0
- adamstat/sdtm.py +270 -0
- adamstat/stats.py +211 -0
- adamstat/visualization.py +170 -0
- adamstat-1.0.0.dist-info/METADATA +197 -0
- adamstat-1.0.0.dist-info/RECORD +22 -0
- adamstat-1.0.0.dist-info/WHEEL +5 -0
- adamstat-1.0.0.dist-info/entry_points.txt +2 -0
- adamstat-1.0.0.dist-info/licenses/LICENSE +21 -0
- adamstat-1.0.0.dist-info/top_level.txt +1 -0
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
|