pycomorb 0.4__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.
- pycomorb/CharlsonComorbidityIndex.py +262 -0
- pycomorb/CustomComorbidityIndex.py +255 -0
- pycomorb/ElixhauserComorbidityIndex.py +153 -0
- pycomorb/GagneComorbidityIndex.py +95 -0
- pycomorb/HospitalFrailtyRiskScore.py +182 -0
- pycomorb/__init__.py +114 -0
- pycomorb/common/CHARLSON_AUSTRALIA.csv +19 -0
- pycomorb/common/CHARLSON_DEYO.csv +19 -0
- pycomorb/common/CHARLSON_DHOORE.csv +19 -0
- pycomorb/common/CHARLSON_QUAN.csv +19 -0
- pycomorb/common/CHARLSON_ROMANO.csv +19 -0
- pycomorb/common/CHARLSON_SWEDEN.csv +20 -0
- pycomorb/common/CHARLSON_UK_SHMI_v1.55.csv +19 -0
- pycomorb/common/ELIXHAUSER.csv +32 -0
- pycomorb/common/ELIXHAUSER_AHRQ_ICD10.csv +40 -0
- pycomorb/common/ELIXHAUSER_AHRQ_ICD9.csv +32 -0
- pycomorb/common/ELIXHAUSER_QUAN.csv +32 -0
- pycomorb/common/ELIXHAUSER_WEIGHTS.csv +32 -0
- pycomorb/common/GAGNE.csv +21 -0
- pycomorb/common/HFRS.csv +110 -0
- pycomorb-0.4.dist-info/METADATA +110 -0
- pycomorb-0.4.dist-info/RECORD +25 -0
- pycomorb-0.4.dist-info/WHEEL +5 -0
- pycomorb-0.4.dist-info/licenses/LICENSE +674 -0
- pycomorb-0.4.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
# Reference for CCI:
|
|
2
|
+
# 1. Charlson ME, Pompei P, Ales KL, MacKenzie CR.
|
|
3
|
+
# A new method of classifying prognostic comorbidity in longitudinal studies: development and validation.
|
|
4
|
+
# J Chronic Dis. 1987;40(5):373-83.
|
|
5
|
+
# doi: 10.1016/0021-9681(87)90171-8. PMID: 3558716.
|
|
6
|
+
# 2. Charlson M, Szatrowski TP, Peterson J, Gold J.
|
|
7
|
+
# Validation of a combined comorbidity index.
|
|
8
|
+
# J Clin Epidemiol. 1994 Nov;47(11):1245-51.
|
|
9
|
+
# doi: 10.1016/0895-4356(94)90129-5. PMID: 7722560.
|
|
10
|
+
#
|
|
11
|
+
# Reference for ICD-9-CM and ICD-10 Coding Algorithms for Charlson Comorbidities:
|
|
12
|
+
# 3. Deyo RA, Cherkin DC, Ciol MA.
|
|
13
|
+
# Adapting a clinical comorbidity index for use with ICD-9-CM administrative databases.
|
|
14
|
+
# J Clin Epidemiol. 1992 Jun;45(6):613-9.
|
|
15
|
+
# doi: 10.1016/0895-4356(92)90133-8. PMID: 1607900.
|
|
16
|
+
# 4. Romano PS, Roos LL, Jollis JG.
|
|
17
|
+
# Adapting a clinical comorbidity index for use with ICD-9-CM administrative data: differing perspectives.
|
|
18
|
+
# J Clin Epidemiol. 1993 Oct;46(10):1075-9; discussion 1081-90.
|
|
19
|
+
# doi: 10.1016/0895-4356(93)90103-8. PMID: 8410092.
|
|
20
|
+
# 5. Quan H, Sundararajan V, Halfon P, Fong A, Burnand B, Luthi JC, Saunders LD, Beck CA, Feasby TE, Ghali WA.
|
|
21
|
+
# Coding algorithms for defining comorbidities in ICD-9-CM and ICD-10 administrative data.
|
|
22
|
+
# Med Care. 2005 Nov;43(11):1130-9.
|
|
23
|
+
# doi: 10.1097/01.mlr.0000182534.19832.83. PMID: 16224307.
|
|
24
|
+
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
import pandas as pd
|
|
28
|
+
import polars as pl
|
|
29
|
+
|
|
30
|
+
# Import the generalized function
|
|
31
|
+
from .CustomComorbidityIndex import CustomComorbidityIndex
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def CharlsonComorbidityIndex(
|
|
35
|
+
df: pl.DataFrame,
|
|
36
|
+
id_col: str = "id",
|
|
37
|
+
code_col: str = "code",
|
|
38
|
+
age_col: str = "age",
|
|
39
|
+
icd_version: str = "icd10",
|
|
40
|
+
icd_version_col: str = None,
|
|
41
|
+
implementation: str = "quan",
|
|
42
|
+
return_categories: bool = False,
|
|
43
|
+
):
|
|
44
|
+
"""
|
|
45
|
+
Calculate the Charlson Comorbidity Index (CCI) using ICD codes and age.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
df (pl.DataFrame): DataFrame with at least columns [id_col, code_col, age_col].
|
|
49
|
+
id_col (str): Name of the column containing unique identifier. Default: "id".
|
|
50
|
+
code_col (str): Name of the column containing ICD codes. Default: "code".
|
|
51
|
+
age_col (str): Name of the column containing patient ages. Default: "age".
|
|
52
|
+
icd_version (str): ICD version ('icd9', 'icd10', or 'icd9_10'). Default: "icd10".
|
|
53
|
+
icd_version_col (str, optional): Name of the column with ICD version for 'icd9_10'. Default: None.
|
|
54
|
+
implementation (str): CCI implementation ('quan', 'deyo', 'romano', 'australia', 'sweden', 'uk_shmi'). Default: "quan".
|
|
55
|
+
return_categories (bool): If True, also return presence indicators for each CCI category.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
- DataFrame with [id_col, "Charlson Score"].
|
|
59
|
+
- DataFrame with category indicators if return_categories is True, else None.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
# Check if input is pandas DataFrame and convert to polars
|
|
63
|
+
is_pandas = pd and isinstance(df, pd.DataFrame)
|
|
64
|
+
if is_pandas:
|
|
65
|
+
df = pl.from_pandas(df)
|
|
66
|
+
|
|
67
|
+
# Change ICD to ICD-9 for Deyo, D'Hoore and Romano
|
|
68
|
+
if icd_version == "icd10" and implementation in [
|
|
69
|
+
"deyo",
|
|
70
|
+
"dhoore",
|
|
71
|
+
"romano",
|
|
72
|
+
]:
|
|
73
|
+
print(f"Warning: Implementation '{implementation}' only uses ICD-9. Setting ICD version to 'icd9'.") # fmt: skip
|
|
74
|
+
icd_version = "icd9"
|
|
75
|
+
# Change ICD to ICD-10 for Australian, Swedish and UK versions
|
|
76
|
+
elif icd_version == "icd9" and implementation in [
|
|
77
|
+
"australia",
|
|
78
|
+
"sweden",
|
|
79
|
+
"uk_shmi",
|
|
80
|
+
]:
|
|
81
|
+
print(f"Warning: Implementation '{implementation}' only uses ICD-10. Setting ICD version to 'icd10'.") # fmt: skip
|
|
82
|
+
icd_version = "icd10"
|
|
83
|
+
|
|
84
|
+
# Input validation specific to Charlson
|
|
85
|
+
assert implementation in [
|
|
86
|
+
"quan",
|
|
87
|
+
"deyo",
|
|
88
|
+
"romano",
|
|
89
|
+
"australia",
|
|
90
|
+
"sweden",
|
|
91
|
+
"uk_shmi",
|
|
92
|
+
], (
|
|
93
|
+
"implementation must be one of: "
|
|
94
|
+
"'quan', 'deyo', 'romano', 'australia', 'sweden', or 'uk_shmi'."
|
|
95
|
+
)
|
|
96
|
+
assert (
|
|
97
|
+
age_col in df.columns
|
|
98
|
+
), f"Column '{age_col}' (age) must be present in input DataFrame."
|
|
99
|
+
assert (
|
|
100
|
+
id_col in df.columns
|
|
101
|
+
), f"Column '{id_col}' (ID) must be present in input DataFrame."
|
|
102
|
+
|
|
103
|
+
# STEP 0: select relevant columns and rename diagnosis code column
|
|
104
|
+
# diagnoses handled by CustomComorbidityIndex
|
|
105
|
+
patient_ages = df.select(id_col, age_col)
|
|
106
|
+
|
|
107
|
+
# STEP 1: Calculate Age Score separately
|
|
108
|
+
# <= 50: 0
|
|
109
|
+
# <= 60: 1
|
|
110
|
+
# <= 70: 2
|
|
111
|
+
# <= 80: 3
|
|
112
|
+
# > 80: 4
|
|
113
|
+
age_scores = (
|
|
114
|
+
patient_ages.with_columns(
|
|
115
|
+
pl.col(age_col)
|
|
116
|
+
.cut(
|
|
117
|
+
breaks=[50, 60, 70, 80],
|
|
118
|
+
labels=["0", "1", "2", "3", "4"],
|
|
119
|
+
left_closed=False, # Age > 50 gets score 1, etc.
|
|
120
|
+
)
|
|
121
|
+
.fill_null("0") # Assume age 0 if null
|
|
122
|
+
.cast(int)
|
|
123
|
+
.alias("Age Score")
|
|
124
|
+
)
|
|
125
|
+
.group_by(id_col)
|
|
126
|
+
.agg(pl.col("Age Score").max().alias("Age Score"))
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# STEP 2: Calculate Comorbidity Score using generalized function
|
|
130
|
+
# Determine definition file based on implementation
|
|
131
|
+
if implementation == "quan":
|
|
132
|
+
definition_file = "CHARLSON_QUAN.csv"
|
|
133
|
+
elif implementation == "deyo":
|
|
134
|
+
definition_file = "CHARLSON_DEYO.csv"
|
|
135
|
+
elif implementation == "dhoore":
|
|
136
|
+
definition_file = "CHARLSON_DHOORE.csv"
|
|
137
|
+
elif implementation == "romano":
|
|
138
|
+
definition_file = "CHARLSON_ROMANO.csv"
|
|
139
|
+
elif implementation == "australia":
|
|
140
|
+
definition_file = "CHARLSON_AUSTRALIA.csv"
|
|
141
|
+
elif implementation == "sweden":
|
|
142
|
+
definition_file = "CHARLSON_SWEDEN.csv"
|
|
143
|
+
elif implementation == "uk_shmi":
|
|
144
|
+
definition_file = "CHARLSON_UK_SHMI_v1.55.csv"
|
|
145
|
+
else:
|
|
146
|
+
# Should be caught by assert earlier
|
|
147
|
+
raise ValueError(f"Unsupported implementation: {implementation}")
|
|
148
|
+
|
|
149
|
+
definition_file_path = Path(__file__).parent / f"common/{definition_file}"
|
|
150
|
+
weight_col_name = "weights"
|
|
151
|
+
|
|
152
|
+
# Use a temporary score name before adding age score
|
|
153
|
+
score_col_name = "Charlson Comorbidity Score"
|
|
154
|
+
|
|
155
|
+
# Define mutual exclusion rules for Charlson
|
|
156
|
+
# These category names are common across Quan, Romano, Deyo
|
|
157
|
+
mutual_exclusion_rules = [
|
|
158
|
+
(
|
|
159
|
+
"Diabetes with chronic complication",
|
|
160
|
+
"Diabetes without chronic complication",
|
|
161
|
+
),
|
|
162
|
+
("Moderate or severe liver disease", "Mild liver disease"),
|
|
163
|
+
]
|
|
164
|
+
# Adjust rules for specific implementations if category names differ
|
|
165
|
+
if implementation == "australia" or implementation == "uk_shmi":
|
|
166
|
+
mutual_exclusion_rules = [
|
|
167
|
+
("Diabetes complications", "Diabetes"),
|
|
168
|
+
("Severe liver disease", "Liver disease"),
|
|
169
|
+
]
|
|
170
|
+
elif implementation == "sweden":
|
|
171
|
+
mutual_exclusion_rules = [
|
|
172
|
+
("Diabetes with end organ damage", "Diabetes"),
|
|
173
|
+
# Sweden uses 'Moderate or severe kidney disease' - no specific liver exclusion rule needed based on provided names
|
|
174
|
+
# Sweden splits Pulmonary disease - no exclusion needed between them
|
|
175
|
+
]
|
|
176
|
+
|
|
177
|
+
df_charlson = CustomComorbidityIndex(
|
|
178
|
+
df=df,
|
|
179
|
+
id_col=id_col,
|
|
180
|
+
code_col=code_col,
|
|
181
|
+
icd_version=icd_version,
|
|
182
|
+
icd_version_col=icd_version_col,
|
|
183
|
+
definition_data=definition_file_path,
|
|
184
|
+
weight_col_name=weight_col_name,
|
|
185
|
+
score_col_name=score_col_name,
|
|
186
|
+
mutual_exclusion_rules=mutual_exclusion_rules,
|
|
187
|
+
return_categories=return_categories,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# STEP 3: Combine Age Score and Comorbidity Score
|
|
191
|
+
final_score_col_name = "Charlson Score"
|
|
192
|
+
df_charlson = df_charlson.join(
|
|
193
|
+
age_scores, on=id_col, how="left", coalesce=True
|
|
194
|
+
).with_columns(
|
|
195
|
+
(pl.col(score_col_name) + pl.col("Age Score")).alias(
|
|
196
|
+
final_score_col_name
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
# Drop Age Score column if not needed
|
|
201
|
+
if not return_categories:
|
|
202
|
+
df_charlson = df_charlson.drop("Age Score")
|
|
203
|
+
|
|
204
|
+
if is_pandas:
|
|
205
|
+
return df_charlson.to_pandas()
|
|
206
|
+
|
|
207
|
+
return df_charlson
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# region 10y survival
|
|
211
|
+
def CharlsonComorbidity_10year_survival(
|
|
212
|
+
df: pl.DataFrame = None,
|
|
213
|
+
id_col: str = "id",
|
|
214
|
+
code_col: str = "code",
|
|
215
|
+
age_col: str = "age",
|
|
216
|
+
icd_version: str = "icd10",
|
|
217
|
+
icd_version_col: str = None,
|
|
218
|
+
implementation: str = "quan",
|
|
219
|
+
precalculated_df: bool = False,
|
|
220
|
+
) -> pl.DataFrame:
|
|
221
|
+
"""
|
|
222
|
+
Calculate the 10-year survival probability based on the Charlson Comorbidity Index.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
df (pl.DataFrame): DataFrame with at least columns [id_col, code_col, age_col] or [id_col, "Charlson Score"] if precalculated_df is True.
|
|
226
|
+
id_col (str): Name of the column containing unique identifier. Default: "id".
|
|
227
|
+
code_col (str): Name of the column containing ICD codes. Default: "code".
|
|
228
|
+
age_col (str): Name of the column containing patient ages. Default: "age".
|
|
229
|
+
icd_version (str): ICD version ('icd9', 'icd10', or 'icd9_10'). Default: "icd10".
|
|
230
|
+
icd_version_col (str, optional): Name of the column with ICD version for 'icd9_10'. Default: None.
|
|
231
|
+
implementation (str): The Charlson implementation. Default: "quan".
|
|
232
|
+
precalculated_df (bool): If True, df is expected to contain a "Charlson Score" column instead of ICD codes and age.
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
pl.DataFrame: DataFrame with columns [id_col, "10-year survival probability"].
|
|
236
|
+
"""
|
|
237
|
+
|
|
238
|
+
if not precalculated_df:
|
|
239
|
+
df, _ = CharlsonComorbidityIndex(
|
|
240
|
+
df=df,
|
|
241
|
+
id_col=id_col,
|
|
242
|
+
code_col=code_col,
|
|
243
|
+
age_col=age_col,
|
|
244
|
+
icd_version=icd_version,
|
|
245
|
+
icd_version_col=icd_version_col,
|
|
246
|
+
implementation=implementation,
|
|
247
|
+
return_categories=False,
|
|
248
|
+
)
|
|
249
|
+
else:
|
|
250
|
+
assert (
|
|
251
|
+
"Charlson Score" in df.columns
|
|
252
|
+
), "Input DataFrame must contain column 'Charlson Score'."
|
|
253
|
+
assert (
|
|
254
|
+
id_col in df.columns
|
|
255
|
+
), f"Input DataFrame must contain column '{id_col}'."
|
|
256
|
+
|
|
257
|
+
# Formula: 0.983 ^ (CCI Score * 0.9)
|
|
258
|
+
return df.with_columns(
|
|
259
|
+
(pl.lit(0.983) ** (pl.col("Charlson Score").clip(lower_bound=0) * 0.9))
|
|
260
|
+
.round(3)
|
|
261
|
+
.alias("10-year survival probability")
|
|
262
|
+
).select(id_col, "10-year survival probability")
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import List, Optional, Tuple, Union
|
|
3
|
+
|
|
4
|
+
import pandas as pd
|
|
5
|
+
import polars as pl
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def CustomComorbidityIndex(
|
|
9
|
+
df: pl.DataFrame,
|
|
10
|
+
id_col: str = "id",
|
|
11
|
+
code_col: str = "code",
|
|
12
|
+
icd_version: str = "icd10",
|
|
13
|
+
icd_version_col: str = None,
|
|
14
|
+
definition_data: Union[Path, pl.DataFrame] = None,
|
|
15
|
+
weight_col_name: str = None,
|
|
16
|
+
score_col_name: str = None,
|
|
17
|
+
mutual_exclusion_rules: Optional[List[Tuple[str, str]]] = None,
|
|
18
|
+
return_categories=False,
|
|
19
|
+
):
|
|
20
|
+
"""
|
|
21
|
+
Generic comorbidity index calculation from ICD codes.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
df (pl.DataFrame): DataFrame with at least columns [id_col, code_col].
|
|
25
|
+
id_col (str): Name of the column containing unique identifier. Default: "id".
|
|
26
|
+
code_col (str): Name of the column containing ICD codes. Default: "code".
|
|
27
|
+
icd_version (str): ICD version ('icd9', 'icd10', or 'icd9_10'). Default: "icd10".
|
|
28
|
+
icd_version_col (str, optional): Name of the column with ICD version for 'icd9_10'. Default: None.
|
|
29
|
+
definition_data (Path or pl.DataFrame): Path to CSV or DataFrame with ICD definitions and weights.
|
|
30
|
+
weight_col_name (str): Name of the column with weights in definition_data.
|
|
31
|
+
score_col_name (str): Name for the calculated score column.
|
|
32
|
+
mutual_exclusion_rules (list of tuple, optional): List of (complicated, uncomplicated) category pairs.
|
|
33
|
+
return_categories (bool): If True, also return presence indicators for each category.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
- DataFrame with [id_col, score_col_name].
|
|
37
|
+
- DataFrame with category indicators if return_categories is True, else None.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
# Check if input is pandas DataFrame and convert to polars
|
|
41
|
+
is_pandas = pd and isinstance(df, pd.DataFrame)
|
|
42
|
+
if is_pandas:
|
|
43
|
+
df = pl.from_pandas(df)
|
|
44
|
+
|
|
45
|
+
# Input validation
|
|
46
|
+
assert icd_version in [
|
|
47
|
+
"icd9",
|
|
48
|
+
"icd10",
|
|
49
|
+
"icd9_10",
|
|
50
|
+
], f"icd_version must be one of: 'icd9', 'icd10', or 'icd9_10'. Got '{icd_version}'."
|
|
51
|
+
assert (
|
|
52
|
+
code_col in df.columns
|
|
53
|
+
), f"Column '{code_col}' (ICD code) must be present in input DataFrame."
|
|
54
|
+
assert (
|
|
55
|
+
id_col in df.columns
|
|
56
|
+
), f"Column '{id_col}' (ID) must be present in input DataFrame."
|
|
57
|
+
if icd_version == "icd9_10":
|
|
58
|
+
assert (
|
|
59
|
+
icd_version_col is not None and icd_version_col in df.columns
|
|
60
|
+
), f"For icd9_10, '{icd_version_col}' (ICD version) column must be present in input DataFrame."
|
|
61
|
+
|
|
62
|
+
# Load definitions
|
|
63
|
+
if isinstance(definition_data, Path):
|
|
64
|
+
definitions = pl.read_csv(definition_data, separator=",")
|
|
65
|
+
else:
|
|
66
|
+
definitions = definition_data
|
|
67
|
+
|
|
68
|
+
assert (
|
|
69
|
+
"category" in definitions.columns
|
|
70
|
+
), "'category' column not found in definition file."
|
|
71
|
+
assert (
|
|
72
|
+
weight_col_name in definitions.columns
|
|
73
|
+
), f"'{weight_col_name}' column not found in definition file."
|
|
74
|
+
|
|
75
|
+
# Drop rows from df with missing codes
|
|
76
|
+
df = df.filter(pl.col(code_col).is_not_null())
|
|
77
|
+
|
|
78
|
+
def process_single_icd(df, icd_col_name):
|
|
79
|
+
# Prepare mapping DataFrame: code_col, category
|
|
80
|
+
code_map_rows = []
|
|
81
|
+
for row in definitions.iter_rows(named=True):
|
|
82
|
+
if row["category"] is None or row.get(icd_col_name) is None:
|
|
83
|
+
continue
|
|
84
|
+
for code in row[icd_col_name].split("|"):
|
|
85
|
+
code_map_rows.append(
|
|
86
|
+
{"prefix": code.strip(), "category": row["category"]}
|
|
87
|
+
)
|
|
88
|
+
code_map = pl.DataFrame(code_map_rows)
|
|
89
|
+
|
|
90
|
+
# Create dataframe with presence indicators
|
|
91
|
+
categories = code_map.select("category").unique()
|
|
92
|
+
categories_list = categories.to_series().to_list()
|
|
93
|
+
categories_df = categories.with_columns(
|
|
94
|
+
pl.when(pl.col("category") == category)
|
|
95
|
+
.then(pl.lit(1))
|
|
96
|
+
.otherwise(pl.lit(0))
|
|
97
|
+
.alias(category)
|
|
98
|
+
for category in categories_list
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Get all unique ICD codes in the data
|
|
102
|
+
code_prefixes = code_map.select("prefix").to_series()
|
|
103
|
+
unique_codes = df.select(code_col).unique()
|
|
104
|
+
longest_code = max(unique_codes.to_series().to_list(), key=len)
|
|
105
|
+
|
|
106
|
+
# Create a DataFrame with all possible prefixes, then find the best match
|
|
107
|
+
best_prefix = (
|
|
108
|
+
unique_codes.with_columns(
|
|
109
|
+
pl.col(code_col).str.slice(0, n).alias(f"prefix_{n}")
|
|
110
|
+
for n in range(1, len(longest_code) + 1)
|
|
111
|
+
)
|
|
112
|
+
.with_columns(
|
|
113
|
+
pl.when(pl.col(f"prefix_{n}").is_in(code_prefixes))
|
|
114
|
+
.then(pl.col(f"prefix_{n}"))
|
|
115
|
+
.otherwise(None)
|
|
116
|
+
.alias(f"prefix_{n}")
|
|
117
|
+
for n in range(1, len(longest_code) + 1)
|
|
118
|
+
)
|
|
119
|
+
# Count ICD code as present for each comorbidity it matches
|
|
120
|
+
.with_columns(
|
|
121
|
+
pl.concat_list(
|
|
122
|
+
pl.col(f"prefix_{n}")
|
|
123
|
+
for n in range(1, len(longest_code) + 1)
|
|
124
|
+
)
|
|
125
|
+
.list.drop_nulls()
|
|
126
|
+
.list.unique()
|
|
127
|
+
.alias("best_prefixes")
|
|
128
|
+
)
|
|
129
|
+
.explode("best_prefixes")
|
|
130
|
+
.rename({"best_prefixes": "best_prefix"})
|
|
131
|
+
.select(code_col, "best_prefix")
|
|
132
|
+
.drop_nulls("best_prefix")
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
(
|
|
136
|
+
unique_codes.with_columns(
|
|
137
|
+
pl.col(code_col).str.slice(0, n).alias(f"prefix_{n}")
|
|
138
|
+
for n in range(1, len(longest_code) + 1)
|
|
139
|
+
)
|
|
140
|
+
.with_columns(
|
|
141
|
+
pl.when(pl.col(f"prefix_{n}").is_in(code_prefixes))
|
|
142
|
+
.then(pl.col(f"prefix_{n}"))
|
|
143
|
+
.otherwise(None)
|
|
144
|
+
.alias(f"prefix_{n}")
|
|
145
|
+
for n in range(1, len(longest_code) + 1)
|
|
146
|
+
)
|
|
147
|
+
.with_columns(
|
|
148
|
+
pl.concat_list(
|
|
149
|
+
pl.col(f"prefix_{n}")
|
|
150
|
+
for n in range(1, len(longest_code) + 1)
|
|
151
|
+
)
|
|
152
|
+
.list.drop_nulls()
|
|
153
|
+
.list.unique()
|
|
154
|
+
.list.join(", ")
|
|
155
|
+
.alias("best_prefixes")
|
|
156
|
+
)
|
|
157
|
+
.write_csv("best_prefix.csv")
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
code_to_category = (
|
|
161
|
+
best_prefix.join(
|
|
162
|
+
code_map, left_on="best_prefix", right_on="prefix", how="left"
|
|
163
|
+
)
|
|
164
|
+
.select(code_col, "category")
|
|
165
|
+
.join(categories_df, on="category", how="left")
|
|
166
|
+
.select(code_col, *categories_list)
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# Join diagnoses with code_to_category to get the best matching category for each diagnosis
|
|
170
|
+
df_presence_absence = (
|
|
171
|
+
df.join(code_to_category, on=code_col, how="left")
|
|
172
|
+
.group_by(id_col)
|
|
173
|
+
.agg(pl.max(cat).fill_null(0).alias(cat) for cat in categories_list)
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# Add missing columns
|
|
177
|
+
for cat in categories_list:
|
|
178
|
+
if cat not in df_presence_absence.columns:
|
|
179
|
+
df_presence_absence = df_presence_absence.with_columns(
|
|
180
|
+
pl.lit(0).alias(cat)
|
|
181
|
+
)
|
|
182
|
+
df_presence_absence = df_presence_absence.select(
|
|
183
|
+
[id_col] + categories_list
|
|
184
|
+
)
|
|
185
|
+
return df_presence_absence, categories_list
|
|
186
|
+
|
|
187
|
+
if icd_version == "icd9_10":
|
|
188
|
+
# Split by icd_version_col
|
|
189
|
+
df_icd9 = df.filter(pl.col(icd_version_col).cast(str) == "ICD-9")
|
|
190
|
+
df_icd10 = df.filter(pl.col(icd_version_col).cast(str) != "ICD-9")
|
|
191
|
+
|
|
192
|
+
# Process each ICD version separately
|
|
193
|
+
df9, cats9 = process_single_icd(df_icd9, "icd9_codes")
|
|
194
|
+
df10, cats10 = process_single_icd(df_icd10, "icd10_codes")
|
|
195
|
+
|
|
196
|
+
# Combine: outer join on id_col, take max for each category
|
|
197
|
+
all_categories = sorted(set(cats9) | set(cats10))
|
|
198
|
+
df_combined = df9.join(df10, on=id_col, how="outer", suffix="_right")
|
|
199
|
+
for cat in all_categories:
|
|
200
|
+
left = cat
|
|
201
|
+
right = f"{cat}_right"
|
|
202
|
+
if left in df_combined.columns and right in df_combined.columns:
|
|
203
|
+
df_combined = df_combined.with_columns(
|
|
204
|
+
pl.max_horizontal(pl.col(left), pl.col(right)).alias(cat)
|
|
205
|
+
)
|
|
206
|
+
elif right in df_combined.columns:
|
|
207
|
+
df_combined = df_combined.rename({right: cat})
|
|
208
|
+
df_presence_absence = df_combined.select([id_col] + all_categories)
|
|
209
|
+
else:
|
|
210
|
+
icd_col_name = f"{icd_version}_codes"
|
|
211
|
+
df_presence_absence, all_categories = process_single_icd(
|
|
212
|
+
df, icd_col_name
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
# STEP 2: calculate score
|
|
216
|
+
category_weights = dict(
|
|
217
|
+
zip(definitions["category"], definitions[weight_col_name])
|
|
218
|
+
)
|
|
219
|
+
category_weights = {
|
|
220
|
+
k: v
|
|
221
|
+
for k, v in category_weights.items()
|
|
222
|
+
if k is not None and v is not None
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
# Apply mutual exclusion rules
|
|
226
|
+
if mutual_exclusion_rules:
|
|
227
|
+
for complicated, uncomplicated in mutual_exclusion_rules:
|
|
228
|
+
if (
|
|
229
|
+
complicated in df_presence_absence.columns
|
|
230
|
+
and uncomplicated in df_presence_absence.columns
|
|
231
|
+
):
|
|
232
|
+
df_presence_absence = df_presence_absence.with_columns(
|
|
233
|
+
pl.when(pl.col(complicated) == 1)
|
|
234
|
+
.then(0)
|
|
235
|
+
.otherwise(pl.col(uncomplicated))
|
|
236
|
+
.alias(uncomplicated)
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
# calculate scores
|
|
240
|
+
# Ensure weights are applied only to existing category columns that have weights
|
|
241
|
+
score_expr = pl.sum_horizontal(
|
|
242
|
+
pl.col(cat) * category_weights.get(cat, 0)
|
|
243
|
+
for cat in all_categories
|
|
244
|
+
if cat in df_presence_absence.columns and cat in category_weights
|
|
245
|
+
).alias(score_col_name)
|
|
246
|
+
|
|
247
|
+
cols = [id_col] + all_categories if return_categories else [id_col]
|
|
248
|
+
comorbidity_df = df_presence_absence.select(*cols, score_expr).cast(
|
|
249
|
+
{score_col_name: int}
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
if is_pandas:
|
|
253
|
+
comorbidity_df = comorbidity_df.to_pandas()
|
|
254
|
+
|
|
255
|
+
return comorbidity_df
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# Reference for ECI:
|
|
2
|
+
# 1. van Walraven C, Austin PC, Jennings A, Quan H, Forster AJ.
|
|
3
|
+
# A modification of the Elixhauser comorbidity measures into a point system for hospital death using administrative data.
|
|
4
|
+
# Med Care. 2009 Jun;47(6):626-33.
|
|
5
|
+
# doi: 10.1097/MLR.0b013e31819432e5. PMID: 19433995.
|
|
6
|
+
# 2. Elixhauser A, Steiner C, Harris DR, Coffey RM.
|
|
7
|
+
# Comorbidity measures for use with administrative data.
|
|
8
|
+
# Med Care. 1998 Jan;36(1):8-27.
|
|
9
|
+
# doi: 10.1097/00005650-199801000-00004. PMID: 9431328.
|
|
10
|
+
#
|
|
11
|
+
# Reference for ICD-9-CM and ICD-10 Coding Algorithms for Elixhauser Comorbidities:
|
|
12
|
+
# 3. Quan H, Sundararajan V, Halfon P, Fong A, Burnand B, Luthi JC, Saunders LD, Beck CA, Feasby TE, Ghali WA.
|
|
13
|
+
# Coding algorithms for defining comorbidities in ICD-9-CM and ICD-10 administrative data.
|
|
14
|
+
# Med Care. 2005 Nov;43(11):1130-9.
|
|
15
|
+
# doi: 10.1097/01.mlr.0000182534.19832.83. PMID: 16224307.
|
|
16
|
+
# 4. Original Elixhauser definitions (used when implementation='elixhauser')
|
|
17
|
+
# 5. AHRQ definitions (used when implementation='ahrq_icd9' or 'ahrq_icd10')
|
|
18
|
+
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import pandas as pd
|
|
22
|
+
import polars as pl
|
|
23
|
+
|
|
24
|
+
# Import the generalized function
|
|
25
|
+
from .CustomComorbidityIndex import CustomComorbidityIndex
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def ElixhauserComorbidityIndex(
|
|
29
|
+
df: pl.DataFrame,
|
|
30
|
+
id_col: str = "id",
|
|
31
|
+
code_col: str = "code",
|
|
32
|
+
icd_version: str = "icd10",
|
|
33
|
+
icd_version_col: str = None,
|
|
34
|
+
implementation: str = "quan",
|
|
35
|
+
weights: str = "van_walraven",
|
|
36
|
+
return_categories=False,
|
|
37
|
+
):
|
|
38
|
+
"""
|
|
39
|
+
Calculate the Elixhauser Comorbidity Index (ECI) using ICD codes.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
df (pl.DataFrame): DataFrame with at least columns [id_col, code_col].
|
|
43
|
+
id_col (str): Name of the column containing unique identifier. Default: "id".
|
|
44
|
+
code_col (str): Name of the column containing ICD codes. Default: "code".
|
|
45
|
+
icd_version (str): ICD version ('icd9', 'icd10', or 'icd9_10'). Default: "icd10".
|
|
46
|
+
icd_version_col (str, optional): Name of the column with ICD version for 'icd9_10'. Default: None.
|
|
47
|
+
implementation (str): Coding algorithm ('quan' or 'elixhauser'). Default: "quan".
|
|
48
|
+
weights (str): Weighting scheme ('van_walraven', 'thompson_30', 'thompson_29', 'ahrq'). Default: "van_walraven".
|
|
49
|
+
return_categories (bool): If True, also return presence indicators for each ECI category.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
- DataFrame with [id_col, score_col_name].
|
|
53
|
+
- DataFrame with category indicators if return_categories is True, else None.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
# Check if input is pandas DataFrame and convert to polars
|
|
57
|
+
is_pandas = pd and isinstance(df, pd.DataFrame)
|
|
58
|
+
if is_pandas:
|
|
59
|
+
df = pl.from_pandas(df)
|
|
60
|
+
|
|
61
|
+
# Change ICD to ICD-9 for original Elixhauser and AHRQ ICD-9
|
|
62
|
+
if icd_version == "icd10" and implementation in [
|
|
63
|
+
"elixhauser",
|
|
64
|
+
"ahrq_icd9",
|
|
65
|
+
]:
|
|
66
|
+
print(f"Warning: Implementation '{implementation}' only uses ICD-9. Setting ICD version to 'icd9'.") # fmt: skip
|
|
67
|
+
icd_version = "icd9"
|
|
68
|
+
# Change ICD to ICD-10 for AHRQ ICD-10
|
|
69
|
+
if icd_version == "icd9" and implementation in ["ahrq_icd10"]:
|
|
70
|
+
print(f"Warning: Implementation '{implementation}' only uses ICD-10. Setting ICD version to 'icd10'.") # fmt: skip
|
|
71
|
+
icd_version = "icd10"
|
|
72
|
+
|
|
73
|
+
# Input validation specific to Elixhauser
|
|
74
|
+
assert implementation in [
|
|
75
|
+
"quan",
|
|
76
|
+
"elixhauser",
|
|
77
|
+
], "implementation must be one of: 'quan', 'elixhauser'."
|
|
78
|
+
assert weights in [
|
|
79
|
+
"van_walraven",
|
|
80
|
+
"thompson_30",
|
|
81
|
+
"thompson_29",
|
|
82
|
+
"ahrq",
|
|
83
|
+
], "weights must be one of: 'van_walraven', 'thompson_30', 'thompson_29', or 'ahrq'."
|
|
84
|
+
|
|
85
|
+
# Determine definition file based on implementation
|
|
86
|
+
if implementation == "quan":
|
|
87
|
+
definition_file = "ELIXHAUSER_QUAN.csv"
|
|
88
|
+
elif implementation == "elixhauser":
|
|
89
|
+
definition_file = "ELIXHAUSER.csv"
|
|
90
|
+
else:
|
|
91
|
+
# Should be caught by assert earlier, but as a safeguard
|
|
92
|
+
raise ValueError(f"Unsupported implementation: {implementation}")
|
|
93
|
+
|
|
94
|
+
# Determine weight column and score column names based on weights argument
|
|
95
|
+
if weights == "van_walraven":
|
|
96
|
+
weight_col_name = "van_walraven_weights"
|
|
97
|
+
score_col_name = "Elixhauser van Walraven Score"
|
|
98
|
+
elif weights == "thompson_30":
|
|
99
|
+
weight_col_name = "Thompson_30_weights"
|
|
100
|
+
score_col_name = "Elixhauser Thompson(30) Score"
|
|
101
|
+
elif weights == "thompson_29":
|
|
102
|
+
weight_col_name = "Thompson_29_weights"
|
|
103
|
+
score_col_name = "Elixhauser Thompson(29) Score"
|
|
104
|
+
elif weights == "ahrq":
|
|
105
|
+
weight_col_name = "AHRQ_weights"
|
|
106
|
+
score_col_name = "Elixhauser AHRQ Score"
|
|
107
|
+
else:
|
|
108
|
+
# Should be caught by assert earlier, but as a safeguard
|
|
109
|
+
raise ValueError(f"Unsupported weights scheme: {weights}")
|
|
110
|
+
|
|
111
|
+
# Load definition and weight files
|
|
112
|
+
base_path = Path(__file__).parent / "common/"
|
|
113
|
+
definition_file_path = base_path / definition_file
|
|
114
|
+
weights_file_path = base_path / "ELIXHAUSER_WEIGHTS.csv"
|
|
115
|
+
|
|
116
|
+
df_definitions = pl.read_csv(definition_file_path)
|
|
117
|
+
df_weights = pl.read_csv(weights_file_path).drop("index")
|
|
118
|
+
|
|
119
|
+
# Join definitions and weights
|
|
120
|
+
# Ensure 'category' column exists in both for joining
|
|
121
|
+
if (
|
|
122
|
+
"category" not in df_definitions.columns
|
|
123
|
+
or "category" not in df_weights.columns
|
|
124
|
+
):
|
|
125
|
+
raise ValueError("Both definition and weight files must contain a 'category' column.") # fmt: skip
|
|
126
|
+
|
|
127
|
+
# Perform the join
|
|
128
|
+
df_combined = df_definitions.join(df_weights, on="category", how="left")
|
|
129
|
+
|
|
130
|
+
# Define mutual exclusion rules for Elixhauser
|
|
131
|
+
mutual_exclusion_rules = [
|
|
132
|
+
("Diabetes complicated", "Diabetes uncomplicated"),
|
|
133
|
+
("Hypertension complicated", "Hypertension uncomplicated"),
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
# Call the generalized function with the combined DataFrame
|
|
137
|
+
df_elixhauser = CustomComorbidityIndex(
|
|
138
|
+
df=df,
|
|
139
|
+
id_col=id_col,
|
|
140
|
+
code_col=code_col,
|
|
141
|
+
icd_version=icd_version,
|
|
142
|
+
icd_version_col=icd_version_col,
|
|
143
|
+
definition_data=df_combined,
|
|
144
|
+
weight_col_name=weight_col_name,
|
|
145
|
+
score_col_name=score_col_name,
|
|
146
|
+
mutual_exclusion_rules=mutual_exclusion_rules,
|
|
147
|
+
return_categories=return_categories,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
if is_pandas:
|
|
151
|
+
return df_elixhauser.to_pandas()
|
|
152
|
+
|
|
153
|
+
return df_elixhauser
|