Skip to content

API

pycomorb.comorbidity(score, df, id_col='id', code_col='code', age_col='age', year_col='year', icd_version='icd10', icd_version_col=None, icd_modification=None, icd_modification_target_year=None, implementation=None, weights=None, definition_data=None, definition_file_path=None, weight_col_name='weights', score_col_name='Custom Comorbidity Score', mutual_exclusion_rules=None, return_categories=False)

Calculate a comorbidity or frailty score from ICD-coded data.

Parameters:

Name Type Description Default
score str

Name of the score to calculate. Supported values are "charlson", "elixhauser", "combined", "hfrs", and "custom" (case-insensitive and including common aliases such as "cci" or "eci").

required
df pl.DataFrame | pd.DataFrame

Input data containing at least id_col and code_col. Additional columns such as age_col or year_col are required depending on the chosen score.

required
id_col str

Column name containing unique identifiers. Defaults to "id".

'id'
code_col str

Column name containing ICD codes. Defaults to "code".

'code'
age_col str

Column name containing patient ages (required for Charlson calculations). Defaults to "age".

'age'
year_col str

Column name containing the year of the ICD code (used when applying ICD modification transfer files). Defaults to "year".

'year'
icd_version str

ICD version; one of "icd9", "icd10", or "icd9_10". Defaults to "icd10".

'icd10'
icd_version_col str

Column name with ICD version labels (required for "icd9_10" inputs). Defaults to None.

None
icd_modification str

ICD modification to apply ("icd10gm" or "icd10cm"). When provided, codes are mapped to the icd_modification_target_year. Defaults to None.

None
icd_modification_target_year int

Target year for ICD modification mappings (ICD-10-GM: 2004-2026, ICD-10-CM: 2015-2027). Defaults to the earliest year of the chosen modification (2004 for "icd10gm", 2015 for "icd10cm").

None
implementation str

Implementation variant for the selected score (for example "quan", "deyo", "elixhauser"). Defaults to None.

None
weights str

Weighting scheme for the Charlson and Elixhauser indices (e.g. "quan", "van_walraven"). Defaults to None, which selects the score's default.

None
definition_data pl.DataFrame | pd.DataFrame | Path | str

Definition table for custom indices, either as a DataFrame or a path to a CSV file. Defaults to None.

None
definition_file_path str

Path to a CSV containing custom index definitions. Ignored when definition_data is a DataFrame. Defaults to None.

None
weight_col_name str

Column name with weights in definition_data. Defaults to "weights".

'weights'
score_col_name str

Column name assigned to the calculated score for custom indices. Defaults to "Custom Comorbidity Score".

'Custom Comorbidity Score'
mutual_exclusion_rules list[tuple[str, str]]

Mutually exclusive category pairs for custom indices, where the second entry is suppressed when the first is present. Both categories must exist in the definitions. Defaults to None.

None
return_categories bool

If True, includes category indicator columns in the result. Defaults to False.

False

Returns:

Type Description
pl.DataFrame | pd.DataFrame

DataFrame containing the calculated score and, when return_categories is True, the category indicators. If the input was a pandas DataFrame, the output matches that type.

Raises:

Type Description
ValueError

If the requested score or icd_modification is not supported, or if a Charlson calculation lacks age_col.

Examples:

Compute a Charlson score with category indicators and automatic ICD-10-GM harmonization:

>>> import polars as pl
>>> from pycomorb import comorbidity
>>> diagnoses = pl.DataFrame(
...     {
...         "id": [1, 1, 2],
...         "code": ["I21", "E119", "C349"],
...         "age": [66, 66, 81],
...         "year": [2012, 2012, 2010],
...     }
... )
>>> result = comorbidity(
...     score="charlson",
...     df=diagnoses,
...     icd_version="icd10",
...     icd_modification="icd10gm",
...     return_categories=True,
... )
>>> result.select("id", "Age Score", "Charlson Age-Comorbidity Score").sort("id")
shape: (2, 3)
┌─────┬───────────┬────────────────────────────────┐
│ id  ┆ Age Score ┆ Charlson Age-Comorbidity Score │
│ --- ┆ ---       ┆ ---                            │
│ i64 ┆ i64       ┆ i64                            │
╞═════╪═══════════╪════════════════════════════════╡
│ 1   ┆ 2         ┆ 4                              │
│ 2   ┆ 4         ┆ 6                              │
└─────┴───────────┴────────────────────────────────┘
Source code in pycomorb/__init__.py
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
def comorbidity(
    score: str,
    df,
    id_col: str = "id",
    code_col: str = "code",
    age_col: str = "age",
    year_col: str = "year",
    icd_version: str = "icd10",
    icd_version_col: str | None = None,
    icd_modification: str | None = None,
    icd_modification_target_year: int | None = None,
    implementation: str | None = None,
    weights: str | None = None,
    definition_data=None,
    definition_file_path: str | None = None,
    weight_col_name: str = "weights",
    score_col_name: str = "Custom Comorbidity Score",
    mutual_exclusion_rules: list[tuple[str, str]] | None = None,
    return_categories: bool = False,
) -> pl.DataFrame | pd.DataFrame:
    """Calculate a comorbidity or frailty score from ICD-coded data.

    Args:
        score (str): Name of the score to calculate. Supported values are ``"charlson"``, ``"elixhauser"``, ``"combined"``, ``"hfrs"``, and ``"custom"`` (case-insensitive and including common aliases such as ``"cci"`` or ``"eci"``).
        df (pl.DataFrame | pd.DataFrame): Input data containing at least ``id_col`` and ``code_col``. Additional columns such as ``age_col`` or ``year_col`` are required depending on the chosen score.
        id_col (str, optional): Column name containing unique identifiers. Defaults to ``"id"``.
        code_col (str, optional): Column name containing ICD codes. Defaults to ``"code"``.
        age_col (str, optional): Column name containing patient ages (required for Charlson calculations). Defaults to ``"age"``.
        year_col (str, optional): Column name containing the year of the ICD code (used when applying ICD modification transfer files). Defaults to ``"year"``.
        icd_version (str, optional): ICD version; one of ``"icd9"``, ``"icd10"``, or ``"icd9_10"``. Defaults to ``"icd10"``.
        icd_version_col (str, optional): Column name with ICD version labels (required for ``"icd9_10"`` inputs). Defaults to ``None``.
        icd_modification (str, optional): ICD modification to apply (``"icd10gm"`` or ``"icd10cm"``). When provided, codes are mapped to the ``icd_modification_target_year``. Defaults to ``None``.
        icd_modification_target_year (int, optional): Target year for ICD modification mappings (ICD-10-GM: 2004-2026, ICD-10-CM: 2015-2027). Defaults to the earliest year of the chosen modification (``2004`` for ``"icd10gm"``, ``2015`` for ``"icd10cm"``).
        implementation (str, optional): Implementation variant for the selected score (for example ``"quan"``, ``"deyo"``, ``"elixhauser"``). Defaults to ``None``.
        weights (str, optional): Weighting scheme for the Charlson and Elixhauser indices (e.g. ``"quan"``, ``"van_walraven"``). Defaults to ``None``, which selects the score's default.
        definition_data (pl.DataFrame | pd.DataFrame | Path | str, optional): Definition table for custom indices, either as a DataFrame or a path to a CSV file. Defaults to ``None``.
        definition_file_path (str, optional): Path to a CSV containing custom index definitions. Ignored when ``definition_data`` is a DataFrame. Defaults to ``None``.
        weight_col_name (str, optional): Column name with weights in ``definition_data``. Defaults to ``"weights"``.
        score_col_name (str, optional): Column name assigned to the calculated score for custom indices. Defaults to ``"Custom Comorbidity Score"``.
        mutual_exclusion_rules (list[tuple[str, str]], optional): Mutually exclusive category pairs for custom indices, where the second entry is suppressed when the first is present. Both categories must exist in the definitions. Defaults to ``None``.
        return_categories (bool, optional): If ``True``, includes category indicator columns in the result. Defaults to ``False``.

    Returns:
        pl.DataFrame | pd.DataFrame: DataFrame containing the calculated score and, when ``return_categories`` is ``True``, the category indicators. If the input was a pandas DataFrame, the output matches that type.

    Raises:
        ValueError: If the requested ``score`` or ``icd_modification`` is not supported, or if a Charlson calculation lacks ``age_col``.

    Examples:
        Compute a Charlson score with category indicators and automatic ICD-10-GM harmonization:

        >>> import polars as pl
        >>> from pycomorb import comorbidity
        >>> diagnoses = pl.DataFrame(
        ...     {
        ...         "id": [1, 1, 2],
        ...         "code": ["I21", "E119", "C349"],
        ...         "age": [66, 66, 81],
        ...         "year": [2012, 2012, 2010],
        ...     }
        ... )
        >>> result = comorbidity(
        ...     score="charlson",
        ...     df=diagnoses,
        ...     icd_version="icd10",
        ...     icd_modification="icd10gm",
        ...     return_categories=True,
        ... )
        >>> result.select("id", "Age Score", "Charlson Age-Comorbidity Score").sort("id")
        shape: (2, 3)
        ┌─────┬───────────┬────────────────────────────────┐
        │ id  ┆ Age Score ┆ Charlson Age-Comorbidity Score │
        │ --- ┆ ---       ┆ ---                            │
        │ i64 ┆ i64       ┆ i64                            │
        ╞═════╪═══════════╪════════════════════════════════╡
        │ 1   ┆ 2         ┆ 4                              │
        │ 2   ┆ 4         ┆ 6                              │
        └─────┴───────────┴────────────────────────────────┘
    """

    # Check if input is pandas DataFrame and convert to polars
    is_pandas = pd and isinstance(df, pd.DataFrame)
    if is_pandas:
        df = pl.from_pandas(df)

    # apply ICD modification if requested
    if icd_modification is not None and "10" in icd_version:
        version_col = icd_version_col if icd_version == "icd9_10" else None
        df, code_col = _apply_modification(
            df, icd_modification, code_col, year_col, icd_modification_target_year, version_col
        )

    # calculate scores
    score = score.lower()
    if score in (
        "cci",
        "charlson",
        "charlsoncomorbidityindex",
        "charlson_comorbidity_index",
    ):
        if age_col not in df.columns:
            raise ValueError(f"Column '{age_col}' (age) must be present in input DataFrame for Charlson calculation.") # fmt: skip
        return_df = CharlsonComorbidityIndex(
            df=df,
            id_col=id_col,
            code_col=code_col,
            age_col=age_col,
            icd_version=icd_version,
            icd_version_col=icd_version_col,
            year_col=year_col,
            implementation=implementation or "quan",
            weights=weights or "charlson",
            return_categories=return_categories,
        )
    elif score in (
        "eci",
        "elixhauser",
        "elixhausercomorbidityindex",
        "elixhauser_comorbidity_index",
    ):
        return_df = ElixhauserComorbidityIndex(
            df=df,
            id_col=id_col,
            code_col=code_col,
            icd_version=icd_version,
            icd_version_col=icd_version_col,
            year_col=year_col,
            implementation=implementation or "quan",
            weights=weights or "van_walraven",
            return_categories=return_categories,
        )
    elif score in (
        "ccs",
        "combined",
        "combinedcomorbidityindex",
        "combined_comorbidity_index",
        "combinedcomorbidityscore",
        "combined_comorbidity_score",
        "gagne",
        "gagnecomorbidityindex",
        "gagne_comorbidity_index",
    ):
        return_df = CombinedComorbidityScore(
            df=df,
            id_col=id_col,
            code_col=code_col,
            icd_version=icd_version,
            icd_version_col=icd_version_col,
            return_categories=return_categories,
        )
    elif score in (
        "hfrs",
        "hospitalfrailtyriskscore",
        "hospital_frailty_risk_score",
    ):
        return_df = HospitalFrailtyRiskScore(
            df=df,
            id_col=id_col,
            code_col=code_col,
            icd_version=icd_version,
            return_categories=return_categories,
        )
    elif score in (
        "custom",
        "customcomorbidityindex",
        "custom_comorbidity_index",
    ):
        return_df = CustomComorbidityIndex(
            df=df,
            id_col=id_col,
            code_col=code_col,
            icd_version=icd_version,
            icd_version_col=icd_version_col,
            definition_data=_as_definitions(definition_data, definition_file_path),
            year_col=year_col if year_col in df.columns else None,
            weight_col_name=weight_col_name,
            score_col_name=score_col_name,
            mutual_exclusion_rules=mutual_exclusion_rules,
            return_categories=return_categories,
        )
    else:
        raise ValueError(
            f"Unknown score: '{score}'. Must be one of: "
            "'charlson', 'elixhauser', 'gagne', 'hfrs' or 'custom'."
        )

    if is_pandas:
        return return_df.to_pandas()

    return return_df

pycomorb.CharlsonComorbidityIndex

CharlsonComorbidityIndex(df, id_col='id', code_col='code', age_col='age', icd_version='icd10', icd_version_col=None, year_col=None, implementation='quan', weights='charlson', return_categories=False)

Calculate the Charlson Comorbidity Index (CCI) using ICD codes and age.

Parameters:

Name Type Description Default
df pl.DataFrame

Input data containing at least id_col, code_col, and age_col.

required
id_col str

Column name containing unique identifiers. Defaults to "id".

'id'
code_col str

Column name containing ICD codes. Defaults to "code".

'code'
age_col str

Column name containing patient ages (age points are only added for weights="charlson"). Defaults to "age".

'age'
icd_version str

ICD version; one of "icd9", "icd10", or "icd9_10". Defaults to "icd10".

'icd10'
icd_version_col str

Column name with ICD version labels when icd_version is "icd9_10". Defaults to None.

None
year_col str

Column name with each record's ICD-10-GM catalogue year. Required when implementation is "sokolowski" (ignored otherwise), since that mapping is year-specific. Defaults to None.

None
implementation str

Definition set to use; "quan", "deyo", "romano", "dhoore", "australia", "sweden", "rcs", "uk_shmi", "sokolowski", or "thygesen". Defaults to "quan".

'quan'
weights str

Weighting scheme; one of "charlson", "schneeweiss", "rcs", "quan", or "uk_shmi". The "rcs" and "uk_shmi" implementations always use their own weights. Defaults to "charlson".

'charlson'
return_categories bool

If True, includes indicator columns for each CCI category. Defaults to False.

False

Returns:

Type Description
pl.DataFrame

DataFrame containing id_col, the "Charlson Age-Comorbidity Score" column, and, when return_categories is True, category indicators and the "Age Score" column.

Raises:

Type Description
AssertionError

If implementation or weights is unsupported, or if required columns are missing.

Source code in pycomorb/CharlsonComorbidityIndex.py
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
def CharlsonComorbidityIndex(
    df: pl.DataFrame,
    id_col: str = "id",
    code_col: str = "code",
    age_col: str = "age",
    icd_version: str = "icd10",
    icd_version_col: str | None = None,
    year_col: str | None = None,
    implementation: str = "quan",
    weights: str = "charlson",
    return_categories: bool = False,
) -> pl.DataFrame:
    """Calculate the Charlson Comorbidity Index (CCI) using ICD codes and age.

    Args:
        df (pl.DataFrame): Input data containing at least ``id_col``, ``code_col``, and ``age_col``.
        id_col (str, optional): Column name containing unique identifiers. Defaults to ``"id"``.
        code_col (str, optional): Column name containing ICD codes. Defaults to ``"code"``.
        age_col (str, optional): Column name containing patient ages (age points are only added for ``weights="charlson"``). Defaults to ``"age"``.
        icd_version (str, optional): ICD version; one of ``"icd9"``, ``"icd10"``, or ``"icd9_10"``. Defaults to ``"icd10"``.
        icd_version_col (str, optional): Column name with ICD version labels when ``icd_version`` is ``"icd9_10"``. Defaults to ``None``.
        year_col (str, optional): Column name with each record's ICD-10-GM catalogue year. Required when ``implementation`` is ``"sokolowski"`` (ignored otherwise), since that mapping is year-specific. Defaults to ``None``.
        implementation (str, optional): Definition set to use; ``"quan"``, ``"deyo"``, ``"romano"``, ``"dhoore"``, ``"australia"``, ``"sweden"``, ``"rcs"``, ``"uk_shmi"``, ``"sokolowski"``, or ``"thygesen"``. Defaults to ``"quan"``.
        weights (str, optional): Weighting scheme; one of ``"charlson"``, ``"schneeweiss"``, ``"rcs"``, ``"quan"``, or ``"uk_shmi"``. The ``"rcs"`` and ``"uk_shmi"`` implementations always use their own weights. Defaults to ``"charlson"``.
        return_categories (bool, optional): If ``True``, includes indicator columns for each CCI category. Defaults to ``False``.

    Returns:
        pl.DataFrame: DataFrame containing ``id_col``, the ``"Charlson Age-Comorbidity Score"`` column, and, when ``return_categories`` is ``True``, category indicators and the ``"Age Score"`` column.

    Raises:
        AssertionError: If ``implementation`` or ``weights`` is unsupported, or if required columns are missing.
    """

    icd_version = coerce_icd_version(icd_version, implementation, ICD9_ONLY, ICD10_ONLY)

    # Input validation specific to Charlson
    assert implementation in DEFINITION_FILES, f"implementation must be one of: {list(DEFINITION_FILES)}."
    assert weights in [
        "charlson",
        "schneeweiss",
        "rcs",
        "quan",
        "uk_shmi",
    ], "weights must be one of: 'charlson', 'schneeweiss', 'rcs', 'quan', or 'uk_shmi'."
    assert age_col in df.columns, f"Column '{age_col}' (age) must be present in input DataFrame." # fmt: skip
    if implementation == "sokolowski":
        assert year_col is not None and year_col in df.columns, "Implementation 'sokolowski' requires a 'year_col' column (ICD-10-GM catalogue year) in the input DataFrame." # fmt: skip

    # STEP 0: diagnoses are handled by CustomComorbidityIndex

    # STEP 1: Calculate Age Score separately (on one age per patient, not per diagnosis row)
    #  < 50: 0
    #  < 60: 1
    #  < 70: 2
    #  < 80: 3
    # >= 80: 4
    age_scores = (
        df.group_by(id_col)
        # an unknown age (null or NaN) gives no age points
        .agg(pl.col(age_col).cast(pl.Float64).fill_nan(None).max().fill_null(0))
        .with_columns(
            pl.when(pl.col(age_col) < 50)
            .then(pl.lit(0))
            .when(pl.col(age_col) < 60)
            .then(pl.lit(1))
            .when(pl.col(age_col) < 70)
            .then(pl.lit(2))
            .when(pl.col(age_col) < 80)
            .then(pl.lit(3))
            .otherwise(pl.lit(4))
            .cast(int)
            .alias("Age Score")
        )
        .select(id_col, "Age Score")
    )

    # STEP 2: Calculate Comorbidity Score using generalized function
    # Determine weight column and score column names based on weights argument
    # rcs/uk_shmi implementations always use their own matching weights
    if implementation in ("rcs", "uk_shmi") and weights != implementation:
        warnings.warn(
            f"Implementation '{implementation}' requires '{implementation}_weights'. Overriding weights='{weights}' with '{implementation}_weights'.",
            UserWarning,
            stacklevel=2,
        )
        weights = implementation
    weight_col_name = f"{weights}_weights"

    df_combined, mutual_exclusion_rules = weighted_definitions(
        "CHARLSON", DEFINITION_FILES[implementation], implementation, CANONICAL_RULES
    )

    df_charlson = CustomComorbidityIndex(
        df=df,
        id_col=id_col,
        code_col=code_col,
        icd_version=icd_version,
        icd_version_col=icd_version_col,
        year_col=year_col if implementation == "sokolowski" else None,
        definition_data=df_combined,
        weight_col_name=weight_col_name,
        score_col_name=SCORE_COL_NAME,
        mutual_exclusion_rules=mutual_exclusion_rules,
        return_categories=return_categories,
    )

    # UK SHMI: a negative Charlson Comorbidity Index is assigned a value of zero
    if weights == "uk_shmi":
        df_charlson = df_charlson.with_columns(pl.col(SCORE_COL_NAME).clip(lower_bound=0))

    # STEP 3: Combine Age Score and Comorbidity Score
    # Only the Charlson weights include age points
    if weights != "charlson":
        age_scores = age_scores.with_columns(pl.lit(0).alias("Age Score"))

    df_charlson = df_charlson.join(
        age_scores, on=id_col, how="left", coalesce=True
    ).with_columns(
        (pl.col(SCORE_COL_NAME) + pl.col("Age Score")).alias(
            FINAL_SCORE_COL_NAME
        )
    )

    # Drop Age Score column if not needed
    if not return_categories:
        df_charlson = df_charlson.drop("Age Score")

    return df_charlson

CharlsonComorbidity_10year_survival(df=None, id_col='id', code_col='code', age_col='age', icd_version='icd10', icd_version_col=None, implementation='quan', precalculated_df=False)

Estimate 10-year survival probabilities from the Charlson Comorbidity Index.

Parameters:

Name Type Description Default
df pl.DataFrame | None

Source data with ICD codes, ages, and identifiers, or a precomputed Charlson score table when precalculated_df is True.

None
id_col str

Column name containing unique identifiers. Defaults to "id".

'id'
code_col str

Column name containing ICD codes. Defaults to "code".

'code'
age_col str

Column name containing patient ages. Defaults to "age".

'age'
icd_version str

ICD version; one of "icd9", "icd10", or "icd9_10". Defaults to "icd10".

'icd10'
icd_version_col str | None

Column name with ICD version labels when icd_version is "icd9_10".

None
implementation str

Charlson definition set passed to :func:CharlsonComorbidityIndex. Defaults to "quan".

'quan'
precalculated_df bool

If True, assumes df already includes a "Charlson Age-Comorbidity Score" column. Defaults to False.

False

Returns:

Type Description
pl.DataFrame

DataFrame with two columns: id_col and "10-year survival probability", calculated as 0.983 ** exp(0.9 * CCI) from the age-adjusted Charlson score (Charlson et al. 1994).

Raises:

Type Description
AssertionError

If required columns are missing when precalculated_df is True.

Source code in pycomorb/CharlsonComorbidityIndex.py
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
def CharlsonComorbidity_10year_survival(
    df: pl.DataFrame = None,
    id_col: str = "id",
    code_col: str = "code",
    age_col: str = "age",
    icd_version: str = "icd10",
    icd_version_col: str | None = None,
    implementation: str = "quan",
    precalculated_df: bool = False,
) -> pl.DataFrame:
    """Estimate 10-year survival probabilities from the Charlson Comorbidity Index.

    Args:
        df (pl.DataFrame | None, optional): Source data with ICD codes, ages, and identifiers, or a precomputed Charlson score table when ``precalculated_df`` is ``True``.
        id_col (str, optional): Column name containing unique identifiers. Defaults to ``"id"``.
        code_col (str, optional): Column name containing ICD codes. Defaults to ``"code"``.
        age_col (str, optional): Column name containing patient ages. Defaults to ``"age"``.
        icd_version (str, optional): ICD version; one of ``"icd9"``, ``"icd10"``, or ``"icd9_10"``. Defaults to ``"icd10"``.
        icd_version_col (str | None, optional): Column name with ICD version labels when ``icd_version`` is ``"icd9_10"``.
        implementation (str, optional): Charlson definition set passed to :func:`CharlsonComorbidityIndex`. Defaults to ``"quan"``.
        precalculated_df (bool, optional): If ``True``, assumes ``df`` already includes a ``"Charlson Age-Comorbidity Score"`` column. Defaults to ``False``.

    Returns:
        pl.DataFrame: DataFrame with two columns: ``id_col`` and ``"10-year survival probability"``, calculated as ``0.983 ** exp(0.9 * CCI)`` from the age-adjusted Charlson score (Charlson et al. 1994).

    Raises:
        AssertionError: If required columns are missing when ``precalculated_df`` is ``True``.
    """

    if not precalculated_df:
        df = CharlsonComorbidityIndex(
            df=df,
            id_col=id_col,
            code_col=code_col,
            age_col=age_col,
            icd_version=icd_version,
            icd_version_col=icd_version_col,
            implementation=implementation,
            return_categories=False,
        )
    else:
        assert (
            FINAL_SCORE_COL_NAME in df.columns
        ), f"Input DataFrame must contain column '{FINAL_SCORE_COL_NAME}'."
        assert (
            id_col in df.columns
        ), f"Input DataFrame must contain column '{id_col}'."

    # Charlson et al. 1994: 10-year survival = 0.983 ^ exp(0.9 * CCI)
    return df.with_columns(
        (pl.lit(0.983) ** (0.9 * pl.col(FINAL_SCORE_COL_NAME)).exp())
        .round(3)
        .alias("10-year survival probability")
    ).select(id_col, "10-year survival probability")

pycomorb.ElixhauserComorbidityIndex

ElixhauserComorbidityIndex(df, id_col='id', code_col='code', icd_version='icd10', icd_version_col=None, year_col=None, implementation='quan', weights='van_walraven', return_categories=False)

Calculate the Elixhauser Comorbidity Index (ECI) using ICD codes.

Parameters:

Name Type Description Default
df pl.DataFrame

Input data containing at least id_col and code_col.

required
id_col str

Column name containing unique identifiers. Defaults to "id".

'id'
code_col str

Column name containing ICD codes. Defaults to "code".

'code'
icd_version str

ICD version; one of "icd9", "icd10", or "icd9_10". Defaults to "icd10".

'icd10'
icd_version_col str

Column name with ICD version labels when icd_version is "icd9_10". Defaults to None.

None
year_col str

Column name with each record's diagnosis year. Required when implementation is "ahrq_icd10" (ignored otherwise), since its POA-exemption changelog is year-specific. Defaults to None.

None
implementation str

Definition set to use; "quan", "elixhauser", "ahrq_icd9", or "ahrq_icd10". Defaults to "quan".

'quan'
weights str

Weighting scheme; one of "van_walraven", "thompson_30", "thompson_29", "ahrq", or "swiss". "ahrq" uses icd_version to pick ahrq_icd9_weights/ahrq_icd10_weights; implementation="ahrq_icd9"/"ahrq_icd10" enforce it regardless of this argument. Defaults to "van_walraven".

'van_walraven'
return_categories bool

If True, includes indicator columns for each Elixhauser category. Defaults to False.

False

Returns:

Type Description
pl.DataFrame

DataFrame containing id_col, the calculated score column, and, when return_categories is True, category indicators.

Raises:

Type Description
AssertionError

If implementation or weights is outside the supported values, or if implementation="ahrq_icd10" and year_col is missing.

ValueError

If weights="ahrq" is used with icd_version="icd9_10".

Source code in pycomorb/ElixhauserComorbidityIndex.py
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
def ElixhauserComorbidityIndex(
    df: pl.DataFrame,
    id_col: str = "id",
    code_col: str = "code",
    icd_version: str = "icd10",
    icd_version_col: str | None = None,
    year_col: str | None = None,
    implementation: str = "quan",
    weights: str = "van_walraven",
    return_categories=False,
) -> pl.DataFrame:
    """Calculate the Elixhauser Comorbidity Index (ECI) using ICD codes.

    Args:
        df (pl.DataFrame): Input data containing at least ``id_col`` and ``code_col``.
        id_col (str, optional): Column name containing unique identifiers. Defaults to ``"id"``.
        code_col (str, optional): Column name containing ICD codes. Defaults to ``"code"``.
        icd_version (str, optional): ICD version; one of ``"icd9"``, ``"icd10"``, or ``"icd9_10"``. Defaults to ``"icd10"``.
        icd_version_col (str, optional): Column name with ICD version labels when ``icd_version`` is ``"icd9_10"``. Defaults to ``None``.
        year_col (str, optional): Column name with each record's diagnosis year. Required when ``implementation`` is ``"ahrq_icd10"`` (ignored otherwise), since its POA-exemption changelog is year-specific. Defaults to ``None``.
        implementation (str, optional): Definition set to use; ``"quan"``, ``"elixhauser"``, ``"ahrq_icd9"``, or ``"ahrq_icd10"``. Defaults to ``"quan"``.
        weights (str, optional): Weighting scheme; one of ``"van_walraven"``, ``"thompson_30"``, ``"thompson_29"``, ``"ahrq"``, or ``"swiss"``. ``"ahrq"`` uses ``icd_version`` to pick ``ahrq_icd9_weights``/``ahrq_icd10_weights``; ``implementation="ahrq_icd9"``/``"ahrq_icd10"`` enforce it regardless of this argument. Defaults to ``"van_walraven"``.
        return_categories (bool, optional): If ``True``, includes indicator columns for each Elixhauser category. Defaults to ``False``.

    Returns:
        pl.DataFrame: DataFrame containing ``id_col``, the calculated score column, and, when ``return_categories`` is ``True``, category indicators.

    Raises:
        AssertionError: If ``implementation`` or ``weights`` is outside the supported values, or if ``implementation="ahrq_icd10"`` and ``year_col`` is missing.
        ValueError: If ``weights="ahrq"`` is used with ``icd_version="icd9_10"``.
    """

    icd_version = coerce_icd_version(icd_version, implementation, ICD9_ONLY, ICD10_ONLY)

    # Input validation specific to Elixhauser
    assert implementation in DEFINITION_FILES, f"implementation must be one of: {list(DEFINITION_FILES)}."
    assert weights in [
        "van_walraven",
        "thompson_30",
        "thompson_29",
        "ahrq",
        "swiss",
    ], "weights must be one of: 'van_walraven', 'thompson_30', 'thompson_29', 'ahrq', or 'swiss'."
    if implementation == "ahrq_icd10":
        assert year_col is not None and year_col in df.columns, "Implementation 'ahrq_icd10' requires a 'year_col' column (diagnosis year) in the input DataFrame." # fmt: skip

    # Determine weight column and score column names based on weights argument
    # AHRQ implementations always use their own AHRQ weights; icd_version
    # (forced above) picks between the ICD-9/ICD-10 column
    if implementation in ("ahrq_icd9", "ahrq_icd10") and weights and weights.lower() != "ahrq":
        warnings.warn(
            f"Implementation '{implementation}' requires 'ahrq' weights. Overriding weights='{weights}' with 'ahrq_weights'.",
            UserWarning,
            stacklevel=2
        )

    if implementation in ("ahrq_icd9", "ahrq_icd10") or weights == "ahrq":
        if icd_version not in ("icd9", "icd10"):
            raise ValueError("weights='ahrq' requires icd_version 'icd9' or 'icd10'.")
        weight_col_name = f"ahrq_{icd_version}_weights"
    else:
        weight_col_name = f"{weights}_weights"

    df_combined, mutual_exclusion_rules = weighted_definitions(
        "ELIXHAUSER", DEFINITION_FILES[implementation], implementation, CANONICAL_RULES
    )

    # Call the generalized function with the combined DataFrame
    df_elixhauser = CustomComorbidityIndex(
        df=df,
        id_col=id_col,
        code_col=code_col,
        icd_version=icd_version,
        icd_version_col=icd_version_col,
        year_col=year_col if implementation == "ahrq_icd10" else None,
        definition_data=df_combined,
        weight_col_name=weight_col_name,
        score_col_name=SCORE_COL_NAME,
        mutual_exclusion_rules=mutual_exclusion_rules,
        return_categories=return_categories,
    )

    return df_elixhauser

pycomorb.CombinedComorbidityScore

CombinedComorbidityScore(df, id_col='id', code_col='code', icd_version='icd10', icd_version_col=None, return_categories=False)

Calculate the Combined Comorbidity Score using ICD codes.

Parameters:

Name Type Description Default
df pl.DataFrame

Input data containing at least id_col and code_col.

required
id_col str

Column name containing unique identifiers. Defaults to "id".

'id'
code_col str

Column name containing ICD codes. Defaults to "code".

'code'
icd_version str

ICD version; one of "icd9", "icd10", or "icd9_10". Defaults to "icd10".

'icd10'
icd_version_col str

Column name with ICD version labels when icd_version is "icd9_10". Defaults to None.

None
return_categories bool

If True, includes indicator columns for each Combined Comorbidity Score category. Defaults to False.

False

Returns:

Type Description
pl.DataFrame

DataFrame containing id_col, the calculated score column, and, when return_categories is True, category indicators.

Raises:

Type Description
AssertionError

If required columns are missing.

Source code in pycomorb/CombinedComorbidityScore.py
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
def CombinedComorbidityScore(
    df: pl.DataFrame,
    id_col: str = "id",
    code_col: str = "code",
    icd_version: str = "icd10",
    icd_version_col: str | None = None,
    return_categories=False,
) -> pl.DataFrame:
    """Calculate the Combined Comorbidity Score using ICD codes.

    Args:
        df (pl.DataFrame): Input data containing at least ``id_col`` and ``code_col``.
        id_col (str, optional): Column name containing unique identifiers. Defaults to ``"id"``.
        code_col (str, optional): Column name containing ICD codes. Defaults to ``"code"``.
        icd_version (str, optional): ICD version; one of ``"icd9"``, ``"icd10"``, or ``"icd9_10"``. Defaults to ``"icd10"``.
        icd_version_col (str, optional): Column name with ICD version labels when ``icd_version`` is ``"icd9_10"``. Defaults to ``None``.
        return_categories (bool, optional): If ``True``, includes indicator columns for each Combined Comorbidity Score category. Defaults to ``False``.

    Returns:
        pl.DataFrame: DataFrame containing ``id_col``, the calculated score column, and, when ``return_categories`` is ``True``, category indicators.

    Raises:
        AssertionError: If required columns are missing.
    """

    # No mutual exclusion rules:
    # - the published Gagne SAS code treats "Any tumor" (1) and "Metastatic cancer" (5)
    #   as independent flags and sums both.
    # - uncomplicated diabetes has weight 0 (and is not considered in the SAS code),
    #   so complicated diabetes does not need to suppress it.
    return CustomComorbidityIndex(
        df=df,
        id_col=id_col,
        code_col=code_col,
        icd_version=icd_version,
        icd_version_col=icd_version_col,
        definition_data=DEFINITION_FILE,
        weight_col_name="weights",
        score_col_name=SCORE_COL_NAME,
        return_categories=return_categories,
    )

pycomorb.HospitalFrailtyRiskScore

HospitalFrailtyRiskScore(df, id_col='id', code_col='code', icd_version='icd10', return_categories=False)

Calculate the Hospital Frailty Risk Score (HFRS) using ICD-10 codes.

Parameters:

Name Type Description Default
df pl.DataFrame | pd.DataFrame

Input data containing at least id_col and code_col.

required
id_col str

Column name containing unique identifiers. Defaults to "id".

'id'
code_col str

Column name containing ICD codes. Defaults to "code".

'code'
icd_version str

ICD version; must be "icd10". Defaults to "icd10".

'icd10'
return_categories bool

If True, includes indicator columns for each HFRS category. Defaults to False.

False

Returns:

Type Description
pl.DataFrame | pd.DataFrame

DataFrame containing id_col, the "HFRS Score" column, and, when return_categories is True, category indicators.

Raises:

Type Description
AssertionError

If icd_version is not "icd10" or required columns are missing.

Source code in pycomorb/HospitalFrailtyRiskScore.py
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
def HospitalFrailtyRiskScore(
    df: pl.DataFrame,
    id_col: str = "id",
    code_col: str = "code",
    icd_version: str = "icd10",
    return_categories=False,
) -> pl.DataFrame:
    """Calculate the Hospital Frailty Risk Score (HFRS) using ICD-10 codes.

    Args:
        df (pl.DataFrame | pd.DataFrame): Input data containing at least ``id_col`` and ``code_col``.
        id_col (str, optional): Column name containing unique identifiers. Defaults to ``"id"``.
        code_col (str, optional): Column name containing ICD codes. Defaults to ``"code"``.
        icd_version (str, optional): ICD version; must be ``"icd10"``. Defaults to ``"icd10"``.
        return_categories (bool, optional): If ``True``, includes indicator columns for each HFRS category. Defaults to ``False``.

    Returns:
        pl.DataFrame | pd.DataFrame: DataFrame containing ``id_col``, the ``"HFRS Score"`` column, and, when ``return_categories`` is ``True``, category indicators.

    Raises:
        AssertionError: If ``icd_version`` is not ``"icd10"`` or required columns are missing.
    """

    is_pandas = isinstance(df, pd.DataFrame)
    if is_pandas:
        df = pl.from_pandas(df)

    assert icd_version == "icd10", "icd_version must be 'icd10' for HFRS."
    assert id_col in df.columns, f"Column '{id_col}' (ID) must be present in input DataFrame." # fmt: skip
    assert code_col in df.columns, f"Column '{code_col}' (ICD code) must be present in input DataFrame." # fmt: skip

    hfrs_df = CustomComorbidityIndex(
        df=df,
        id_col=id_col,
        code_col=code_col,
        icd_version=icd_version,
        definition_data=DEFINITION_FILE,
        weight_col_name="weight",
        score_col_name=SCORE_COL_NAME,
        return_categories=return_categories,
    ).with_columns(pl.col(SCORE_COL_NAME).round(1))

    return hfrs_df.to_pandas() if is_pandas else hfrs_df

pycomorb.CustomComorbidityIndex

CustomComorbidityIndex(df, id_col='id', code_col='code', icd_version='icd10', icd_version_col=None, definition_data=None, weight_col_name=None, score_col_name=None, mutual_exclusion_rules=None, year_col=None, return_categories=False)

Compute a custom comorbidity score from ICD-coded input data.

Parameters:

Name Type Description Default
df pl.DataFrame

Input data containing at least id_col and code_col.

required
id_col str

Column name containing unique identifiers. Defaults to "id".

'id'
code_col str

Column name containing ICD codes. Defaults to "code".

'code'
icd_version str

ICD version; one of "icd9", "icd10", or "icd9_10". Defaults to "icd10".

'icd10'
icd_version_col str

Column name with ICD version labels when icd_version is "icd9_10". Defaults to None.

None
definition_data Path | pl.DataFrame

Path to a CSV file or DataFrame containing category definitions and weights. May optionally include an and_group column: rows sharing a category and a non-empty and_group value must all be present (AND) for that group to count, OR'd into the category's normal presence; blank and_group behaves as before. Defaults to None.

None
weight_col_name str

Column name with weights inside definition_data. Defaults to None.

None
score_col_name str

Column name assigned to the calculated score in the result. Defaults to None.

None
mutual_exclusion_rules list[tuple[str, str]]

Pairs of mutually exclusive categories where the second entry is suppressed when the first is present. Defaults to None.

None
year_col str

Column name with each record's definition-catalogue year (e.g. ICD-10-GM catalogue year). When provided, definition_data must include a year column, and a code only matches a category as of the definition row's year onward (df[year_col] >= definitions["year"]). Defaults to None (year-independent matching, the historical behavior).

None
return_categories bool

If True, includes indicator columns for each matched category. Defaults to False.

False

Returns:

Type Description
pl.DataFrame

DataFrame containing id_col, the calculated score column, and, when return_categories is True, category indicators.

Patients without a usable code are kept with all categories 0.

Raises:

Type Description
AssertionError

If required columns or definitions are missing, or if icd_version and icd_version_col are inconsistent.

ValueError

If code_col holds numbers instead of strings, or if mutual_exclusion_rules name a category that is not in the definitions.

Source code in pycomorb/CustomComorbidityIndex.py
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
def CustomComorbidityIndex(
    df: pl.DataFrame,
    id_col: str = "id",
    code_col: str = "code",
    icd_version: str = "icd10",
    icd_version_col: str | None = None,
    definition_data: Path | pl.DataFrame | None = None,
    weight_col_name: str | None = None,
    score_col_name: str | None = None,
    mutual_exclusion_rules: list[tuple[str, str]] | None = None,
    year_col: str | None = None,
    return_categories=False,
) -> pl.DataFrame:
    """Compute a custom comorbidity score from ICD-coded input data.

    Args:
        df (pl.DataFrame): Input data containing at least ``id_col`` and ``code_col``.
        id_col (str, optional): Column name containing unique identifiers. Defaults to ``"id"``.
        code_col (str, optional): Column name containing ICD codes. Defaults to ``"code"``.
        icd_version (str, optional): ICD version; one of ``"icd9"``, ``"icd10"``, or ``"icd9_10"``. Defaults to ``"icd10"``.
        icd_version_col (str, optional): Column name with ICD version labels when ``icd_version`` is ``"icd9_10"``. Defaults to ``None``.
        definition_data (Path | pl.DataFrame, optional): Path to a CSV file or DataFrame containing category definitions and weights. May optionally include an ``and_group`` column: rows sharing a ``category`` and a non-empty ``and_group`` value must all be present (AND) for that group to count, OR'd into the category's normal presence; blank ``and_group`` behaves as before. Defaults to ``None``.
        weight_col_name (str, optional): Column name with weights inside ``definition_data``. Defaults to ``None``.
        score_col_name (str, optional): Column name assigned to the calculated score in the result. Defaults to ``None``.
        mutual_exclusion_rules (list[tuple[str, str]], optional): Pairs of mutually exclusive categories where the second entry is suppressed when the first is present. Defaults to ``None``.
        year_col (str, optional): Column name with each record's definition-catalogue year (e.g. ICD-10-GM catalogue year). When provided, ``definition_data`` must include a ``year`` column, and a code only matches a category as of the definition row's year onward (``df[year_col] >= definitions["year"]``). Defaults to ``None`` (year-independent matching, the historical behavior).
        return_categories (bool, optional): If ``True``, includes indicator columns for each matched category. Defaults to ``False``.

    Returns:
        pl.DataFrame: DataFrame containing ``id_col``, the calculated score column, and, when ``return_categories`` is ``True``, category indicators.

    Patients without a usable code are kept with all categories 0.

    Raises:
        AssertionError: If required columns or definitions are missing, or if ``icd_version`` and ``icd_version_col`` are inconsistent.
        ValueError: If ``code_col`` holds numbers instead of strings, or if ``mutual_exclusion_rules`` name a category that is not in the definitions.
    """

    # Input validation
    assert icd_version in [
        "icd9",
        "icd10",
        "icd9_10",
    ], f"icd_version must be one of: 'icd9', 'icd10', or 'icd9_10'. Got '{icd_version}'."
    assert id_col in df.columns, f"Column '{id_col}' (ID) must be present in input DataFrame." # fmt: skip
    assert code_col in df.columns, f"Column '{code_col}' (ICD code) must be present in input DataFrame." # fmt: skip
    if icd_version == "icd9_10":
        assert icd_version_col is not None and icd_version_col in df.columns, f"For icd9_10, '{icd_version_col}' (ICD version) column must be present in input DataFrame." # fmt: skip

    # Load definitions
    if isinstance(definition_data, Path):
        definitions = pl.read_csv(definition_data, separator=",")
    else:
        definitions = definition_data

    assert (
        "category" in definitions.columns
    ), "'category' column not found in definition file."
    assert (
        weight_col_name in definitions.columns
    ), f"'{weight_col_name}' column not found in definition file."

    df = check_code_dtype(df, code_col)

    # Year gating only applies when both year_col and a year column are given
    year_aware = year_col is not None and "year" in definitions.columns

    def process_single_icd(df, icd_col_name):
        # Patients without a usable code stay in the result with all categories 0
        ids = df.select(id_col).unique()
        df = df.filter(pl.col(code_col).is_not_null())

        # Validation and cleaning only touch the unique codes, the diagnosis rows keep their
        # raw codes and are joined on them below. Blank codes match nothing.
        unique_codes = (
            df.select(code_col)
            .unique()
            .with_columns(clean_code_expr(code_col).alias("clean"))
            .filter(pl.col("clean") != "")
        )

        categories_list = definitions["category"].drop_nulls().unique(maintain_order=True).to_list()
        if unique_codes.is_empty():  # no codes of this ICD version: nothing present
            return ids.with_columns(pl.lit(0).cast(int).alias(c) for c in categories_list), categories_list

        # Prepare mapping DataFrame: prefix, bit, since. Every definition row is one bit in a
        # per-patient mask; a category is present if all bits of one of its groups are set,
        # where a group is a single row or the rows sharing an and_group.
        required, code_map_rows = defaultdict(lambda: defaultdict(int)), []
        for i, row in enumerate(definitions.iter_rows(named=True)):
            if row["category"] is None or row.get(icd_col_name) is None:
                continue
            bit = 1 << i
            required[row["category"]][row.get("and_group") or i] |= bit
            for code in row[icd_col_name].split("|"):
                code_map_rows.append(
                    {"prefix": code.strip(), "bit": bit, "since": row["year"] if year_aware else 0}
                )
        code_map = pl.DataFrame(code_map_rows)

        code_prefixes = code_map.select("prefix").to_series().to_list()
        longest_code = max(unique_codes["clean"].to_list(), key=len)

        # Create a DataFrame with all possible prefixes, then find the best match
        best_prefix = (
            unique_codes.with_columns(
                pl.col("clean").str.slice(0, n).alias(f"prefix_{n}")
                for n in range(1, len(longest_code) + 1)
            )
            .with_columns(
                pl.when(pl.col(f"prefix_{n}").is_in(code_prefixes))
                .then(pl.col(f"prefix_{n}"))
                .otherwise(None)
                .alias(f"prefix_{n}")
                for n in range(1, len(longest_code) + 1)
            )
            # Count ICD code as present for each comorbidity it matches
            .unpivot(
                on=[f"prefix_{n}" for n in range(1, len(longest_code) + 1)],
                index=code_col,
                value_name="best_prefix",
            )
            .drop_nulls("best_prefix")
            .select(code_col, "best_prefix")
            .unique()
        )

        # Join diagnoses with the bits of their code; only matching diagnoses are aggregated
        matches = df.join(
            best_prefix.join(
                code_map, left_on="best_prefix", right_on="prefix"
            ).select(code_col, "bit", "since"),
            on=code_col,
        )
        if year_aware:
            matches = matches.filter(pl.col(year_col) >= pl.col("since"))

        masks_by_id = matches.group_by(id_col).agg(pl.col("bit").bitwise_or().alias("mask"))
        df_presence_absence = (
            ids.join(masks_by_id, on=id_col, how="left")
            .with_columns(pl.col("mask").fill_null(0))
            .select(
                id_col,
                *(
                    pl.any_horizontal((pl.col("mask") & m) == m for m in masks.values())
                    .cast(int)
                    .alias(cat)
                    for cat, masks in required.items()
                ),
            )
        )

        # Add missing columns
        for cat in categories_list:
            if cat not in df_presence_absence.columns:
                df_presence_absence = df_presence_absence.with_columns(
                    pl.lit(0).cast(int).alias(cat)
                )
        df_presence_absence = df_presence_absence.select(
            [id_col] + categories_list
        )
        return df_presence_absence, categories_list

    if icd_version == "icd9_10":
        # Split by icd_version_col
        df_icd9 = df.filter(pl.col(icd_version_col).cast(str) == "ICD-9")
        df_icd10 = df.filter(pl.col(icd_version_col).cast(str) != "ICD-9")

        # Process each ICD version separately
        df9, cats9 = process_single_icd(df_icd9, "icd9_codes")
        df10, cats10 = process_single_icd(df_icd10, "icd10_codes")

        # Combine: outer join on id_col, take max for each category
        # Preserve definition file order instead of alphabetical sorting
        all_categories_set = set(cats9) | set(cats10)
        all_categories = [
            cat
            for cat in dict.fromkeys(definitions["category"].to_list())
            if cat in all_categories_set and cat is not None
        ]
        df_combined = df9.join(
            df10, on=id_col, how="full", suffix="_right", coalesce=True
        )
        # both halves report every category, so each has a "_right" counterpart
        df_presence_absence = df_combined.select(
            id_col,
            *(pl.max_horizontal(cat, f"{cat}_right").alias(cat) for cat in all_categories),
        )
    else:
        icd_col_name = f"{icd_version}_codes"
        df_presence_absence, all_categories = process_single_icd(
            df, icd_col_name
        )

    # STEP 2: calculate score
    category_weights = dict(
        zip(definitions["category"], definitions[weight_col_name])
    )
    category_weights = {
        k: v
        for k, v in category_weights.items()
        if k is not None and v is not None
    }

    # Apply mutual exclusion rules
    for complicated, uncomplicated in mutual_exclusion_rules or []:
        unknown = {complicated, uncomplicated} - set(df_presence_absence.columns)
        if unknown:
            raise ValueError(f"mutual_exclusion_rules refer to unknown categories: {sorted(unknown)}.") # fmt: skip
        df_presence_absence = df_presence_absence.with_columns(
            pl.when(pl.col(complicated) == 1)
            .then(0)
            .otherwise(pl.col(uncomplicated))
            .alias(uncomplicated)
        )

    # calculate scores
    # Ensure weights are applied only to existing category columns that have weights
    score_expr = pl.sum_horizontal(
        pl.col(cat) * category_weights.get(cat, 0)
        for cat in all_categories
        if cat in df_presence_absence.columns and cat in category_weights
    ).alias(score_col_name)

    cols = [id_col] + all_categories if return_categories else [id_col]
    return df_presence_absence.select(*cols, score_expr)

coerce_icd_version(icd_version, implementation, icd9_only, icd10_only)

Replace an ICD version the implementation cannot use, with a warning.

Source code in pycomorb/CustomComorbidityIndex.py
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
def coerce_icd_version(icd_version, implementation, icd9_only, icd10_only):
    """Replace an ICD version the implementation cannot use, with a warning."""
    if implementation in icd9_only:
        version, label = "icd9", "ICD-9"
    elif implementation in icd10_only:
        version, label = "icd10", "ICD-10"
    else:
        return icd_version
    if icd_version in ("icd9", "icd10", "icd9_10") and icd_version != version:
        warnings.warn(
            f"Implementation '{implementation}' only uses {label}. Setting ICD version to '{version}'.",
            UserWarning,
            stacklevel=3,
        )
        return version
    return icd_version

weighted_definitions(prefix, definition_file, implementation, canonical_rules)

Definitions of a Charlson/Elixhauser implementation joined with its weights, plus its exclusion rules.

prefix ("CHARLSON" or "ELIXHAUSER") names the weight and alias files; canonical_rules are (more severe, milder) pairs in canonical category names, translated to the implementation's own names.

Source code in pycomorb/CustomComorbidityIndex.py
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
def weighted_definitions(prefix, definition_file, implementation, canonical_rules):
    """Definitions of a Charlson/Elixhauser implementation joined with its weights, plus its exclusion rules.

    ``prefix`` ("CHARLSON" or "ELIXHAUSER") names the weight and alias files; ``canonical_rules`` are
    (more severe, milder) pairs in canonical category names, translated to the implementation's own names.
    """
    base_path = Path(__file__).parent / "common"
    definitions = pl.read_csv(base_path / definition_file)
    weights = pl.read_csv(base_path / f"{prefix}_WEIGHTS.csv").drop("index")
    with open(base_path / f"{prefix}_CATEGORY_ALIASES.yaml") as f:
        aliases = yaml.safe_load(f).get(implementation, {})
    combined = (
        definitions.with_columns(pl.col("category").replace(aliases).alias("__weight_category__"))
        .join(weights.rename({"category": "__weight_category__"}), on="__weight_category__", how="left")
        .drop("__weight_category__")
    )
    canonical = {c: aliases.get(c, c) for c in combined["category"]}
    rules = [
        (severe, mild)
        for severe, mild in product(canonical, repeat=2)
        if (canonical[severe], canonical[mild]) in canonical_rules
    ]
    return combined, rules

pycomorb.comorbidity_reporting(score, implementation=None, weights=None, icd_version='icd10', icd_modification=None, path=None, **study_info)

Reporting template for a comorbidity score, with the information needed to reproduce it.

Parameters:

Name Type Description Default
score str

"charlson", "elixhauser", "combined" or "hfrs".

required
implementation str

As in the score function. Defaults to its default (none for "combined" and "hfrs").

None
weights str

As in the score function. Defaults to its default (none for "combined" and "hfrs").

None
icd_version str

ICD version of the data; replaced by the one the implementation uses if it supports a single version only. Defaults to "icd10".

'icd10'
icd_modification str

ICD modification, e.g. "icd10gm", if the codes were mapped to a target year first. Defaults to None.

None
path Path | str

Directory to write the tables to, as template.md (a markdown table), weights.csv, codes.csv and hierarchy.csv. Defaults to None (nothing is written).

None
**study_info str

Answers for the domains that depend on the study data: data_sources, method_of_data_extraction, coding_practice, look_back_period and timing_of_data_extraction. Unanswered domains are left empty.

{}

Returns:

Type Description
tuple[pl.DataFrame, pl.DataFrame, pl.DataFrame, pl.DataFrame]

template (domain, information), weights (row, category, weight), codes (row, category, icd_version, code; one row per ICD code) and hierarchy (severe, mild). row is the definition row a weight and its codes belong to.

Raises:

Type Description
AssertionError

If score, implementation, weights, icd_version or a study_info key is not supported.

Source code in pycomorb/Reporting.py
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
def comorbidity_reporting(
    score: str,
    implementation: str | None = None,
    weights: str | None = None,
    icd_version: str = "icd10",
    icd_modification: str | None = None,
    path: Path | str | None = None,
    **study_info: str,
) -> tuple[pl.DataFrame, pl.DataFrame, pl.DataFrame, pl.DataFrame]:
    """Reporting template for a comorbidity score, with the information needed to reproduce it.

    Args:
        score (str): ``"charlson"``, ``"elixhauser"``, ``"combined"`` or ``"hfrs"``.
        implementation (str, optional): As in the score function. Defaults to its default (none for ``"combined"`` and ``"hfrs"``).
        weights (str, optional): As in the score function. Defaults to its default (none for ``"combined"`` and ``"hfrs"``).
        icd_version (str, optional): ICD version of the data; replaced by the one the implementation uses if it supports a single version only. Defaults to ``"icd10"``.
        icd_modification (str, optional): ICD modification, e.g. ``"icd10gm"``, if the codes were mapped to a target year first. Defaults to ``None``.
        path (Path | str, optional): Directory to write the tables to, as ``template.md`` (a markdown table), ``weights.csv``, ``codes.csv`` and ``hierarchy.csv``. Defaults to ``None`` (nothing is written).
        **study_info (str): Answers for the domains that depend on the study data: ``data_sources``, ``method_of_data_extraction``, ``coding_practice``, ``look_back_period`` and ``timing_of_data_extraction``. Unanswered domains are left empty.

    Returns:
        tuple[pl.DataFrame, pl.DataFrame, pl.DataFrame, pl.DataFrame]: ``template`` (domain, information), ``weights`` (row, category, weight), ``codes`` (row, category, icd_version, code; one row per ICD code) and ``hierarchy`` (severe, mild). ``row`` is the definition row a weight and its codes belong to.

    Raises:
        AssertionError: If ``score``, ``implementation``, ``weights``, ``icd_version`` or a ``study_info`` key is not supported.
    """

    # Input validation
    assert score in SCORES, f"score must be one of: {list(SCORES)}."  # fmt: skip
    assert icd_version in ICD_PARTS, f"icd_version must be one of: {list(ICD_PARTS)}."  # fmt: skip
    assert study_info.keys() <= STUDY_INFO.keys(), f"study_info keys must be among: {list(STUDY_INFO)}."  # fmt: skip

    # STEP 1: Collect what is needed to reproduce the score
    setup = score_setup(score, implementation, weights, icd_version)

    # STEP 2: Build the template and the tables
    tables = {
        "template": report_template(setup, icd_modification, study_info),
        **report_tables(setup),
    }

    # STEP 3: Write the files
    if path is not None:
        Path(path).mkdir(parents=True, exist_ok=True)
        for name, table in tables.items():
            if name == "template":
                (Path(path) / "template.md").write_text(markdown_table(table), encoding="utf-8")  # fmt: skip
            else:
                table.write_csv(Path(path) / f"{name}.csv")
    return tuple(tables.values())

pycomorb.read_reporting(path)

Read the tables written by comorbidity_reporting back in.

The result holds the arguments of a custom score that reproduces the reported one::

comorbidity("custom", df, **read_reporting(path))

icd_version is the one the report used, so do not pass it again; icd_version_col is still needed for "icd9_10". Records need a year column (year_col, default "year") if the codes have a year.

Returns:

Type Description
dict

icd_version, definition_data, weight_col_name and mutual_exclusion_rules.

Raises:

Type Description
FileNotFoundError

If one of the tables is missing in path.

ValueError

If the tables do not belong together.

Source code in pycomorb/Reporting.py
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
def read_reporting(path: Path | str) -> dict:
    """Read the tables written by ``comorbidity_reporting`` back in.

    The result holds the arguments of a custom score that reproduces the reported one::

        comorbidity("custom", df, **read_reporting(path))

    ``icd_version`` is the one the report used, so do not pass it again; ``icd_version_col`` is still needed for ``"icd9_10"``.
    Records need a year column (``year_col``, default ``"year"``) if the codes have a ``year``.

    Returns:
        dict: ``icd_version``, ``definition_data``, ``weight_col_name`` and ``mutual_exclusion_rules``.

    Raises:
        FileNotFoundError: If one of the tables is missing in ``path``.
        ValueError: If the tables do not belong together.
    """

    # Input validation
    path = Path(path)
    for name in ("weights", "codes", "hierarchy"):
        if not (path / f"{name}.csv").is_file():
            raise FileNotFoundError(f"'{name}.csv' not found in '{path}'.")

    # Load the tables; codes stay strings (e.g. ICD-9 "0010" is not a number)
    weights = pl.read_csv(path / "weights.csv")
    rules = pl.read_csv(path / "hierarchy.csv")
    codes = pl.read_csv(
        path / "codes.csv",
        schema_overrides={"code": pl.String, "and_group": pl.String},
    )
    if not set(codes["row"]) <= set(weights["row"]):
        raise ValueError("codes.csv has rows that are not in weights.csv.")
    icd_version = ICD_BY_PARTS.get(frozenset(codes["icd_version"]))
    if icd_version is None:
        raise ValueError(f"icd_version in codes.csv must be among: {list(CODE_COLUMNS)}.")  # fmt: skip

    # Rebuild the definitions. One definition row per code row: its codes joined by "|", one column per ICD version
    code_lists = (
        codes.group_by("row", "icd_version", maintain_order=True)
        .agg(pl.col("code").str.join("|"))
        .pivot(on="icd_version", index="row", values="code")
        .rename(CODE_COLUMNS, strict=False)
    )
    definitions = (
        codes.drop("icd_version", "code")
        .unique("row", maintain_order=True)
        .join(code_lists, on="row")
        .join(weights.select("row", "weight"), on="row")
        .drop("row")
    )

    return {
        "icd_version": icd_version,
        "definition_data": definitions,
        "weight_col_name": "weight",
        "mutual_exclusion_rules": rules.rows(),
    }

pycomorb.get_icd10gm(data, code_col='code', year_col='year', target_year=2024)

Map ICD-10-GM codes to a target year using official transfer files.

Parameters:

Name Type Description Default
data pl.DataFrame

Input data containing at least code_col and year_col.

required
code_col str

Column name containing ICD codes. Defaults to "code".

'code'
year_col str

Column name containing the year of each ICD code. Defaults to "year".

'year'
target_year int

Target ICD-10-GM year. Defaults to 2024.

2024

Returns:

Type Description
pl.DataFrame

Input DataFrame with an additional f"icd_{target_year}" column containing mapped ICD-10-GM codes.

Raises:

Type Description
AssertionError

If target_year is outside the supported range or required columns are missing.

Source code in pycomorb/ICDModifications.py
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
def get_icd10gm(
    data: pl.DataFrame, code_col="code", year_col="year", target_year=2024
) -> pl.DataFrame:
    """Map ICD-10-GM codes to a target year using official transfer files.

    Args:
        data (pl.DataFrame): Input data containing at least ``code_col`` and ``year_col``.
        code_col (str, optional): Column name containing ICD codes. Defaults to ``"code"``.
        year_col (str, optional): Column name containing the year of each ICD code. Defaults to ``"year"``.
        target_year (int, optional): Target ICD-10-GM year. Defaults to ``2024``.

    Returns:
        pl.DataFrame: Input DataFrame with an additional ``f"icd_{target_year}"`` column containing mapped ICD-10-GM codes.

    Raises:
        AssertionError: If ``target_year`` is outside the supported range or required columns are missing.
    """

    # Load all transfer files into a dictionary of DataFrames
    transfer_file_path = Path(__file__).parent / "common/modification_DE/icd10gm.csv" # fmt: skip

    return get_icdmodification(
        data=data,
        transfer_file_path=transfer_file_path,
        code_col=code_col,
        year_col=year_col,
        target_year=target_year,
    )

pycomorb.get_icd10cm(data, code_col='code', year_col='year', target_year=2024)

Map ICD-10-CM codes to a target year using official transfer files.

Parameters:

Name Type Description Default
data pl.DataFrame

Input data containing at least code_col and year_col.

required
code_col str

Column name containing ICD codes. Defaults to "code".

'code'
year_col str

Column name containing the year of each ICD code. Defaults to "year".

'year'
target_year int

Target ICD-10-CM year. Defaults to 2024.

2024

Returns:

Type Description
pl.DataFrame

Input DataFrame with an additional f"icd_{target_year}" column containing mapped ICD-10-CM codes.

Raises:

Type Description
AssertionError

If target_year is outside the supported range or required columns are missing.

Source code in pycomorb/ICDModifications.py
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
def get_icd10cm(
    data: pl.DataFrame, code_col="code", year_col="year", target_year=2024
) -> pl.DataFrame:
    """Map ICD-10-CM codes to a target year using official transfer files.

    Args:
        data (pl.DataFrame): Input data containing at least ``code_col`` and ``year_col``.
        code_col (str, optional): Column name containing ICD codes. Defaults to ``"code"``.
        year_col (str, optional): Column name containing the year of each ICD code. Defaults to ``"year"``.
        target_year (int, optional): Target ICD-10-CM year. Defaults to ``2024``.

    Returns:
        pl.DataFrame: Input DataFrame with an additional ``f"icd_{target_year}"`` column containing mapped ICD-10-CM codes.

    Raises:
        AssertionError: If ``target_year`` is outside the supported range or required columns are missing.
    """

    # Load all transfer files into a dictionary of DataFrames
    transfer_file_path = Path(__file__).parent / "common/modification_US/icd10cm.csv" # fmt: skip

    return get_icdmodification(
        data=data,
        transfer_file_path=transfer_file_path,
        code_col=code_col,
        year_col=year_col,
        target_year=target_year,
    )