from __future__ import annotations
import os
import pickle
from abc import ABC, abstractmethod
import pandas as pd
import polars as pl
from corr_vars import __, logger, utils
from corr_vars.core.variable import FreeDaysVariable
from corr_vars.core.variable import Variable as BaseVariable
from corr_vars.definitions.constants import UNBOUNDED_TMAX, UNBOUNDED_TMIN
from corr_vars.sources.local_datasource import helpers
from corr_vars.utils.base import column_selector, row_length
from corr_vars.utils.frames import time_difference
from corr_vars.utils.time import TimeAnchor, TimeWindow
from corr_vars.definitions.typing import ObsLevel, VariableProtocol
from typing import TYPE_CHECKING, Literal
if TYPE_CHECKING:
from corr_vars.core import Cohort
from corr_vars.definitions import (
CleaningDict,
RequirementsDict,
RequirementsIterable,
TimeAnchorColumn,
VariableCallable,
)
from corr_vars.sources.var_loader import MultiSourceVariable
from corr_vars.definitions.typing import ObsLevelName
def _n_rows(data: pl.DataFrame | None) -> int:
"""Row count of ``data``, or ``0`` if it has not been extracted yet."""
return 0 if data is None else len(data)
[docs]
def VariableLoader(*args, **kwargs) -> BaseVariable:
"""Factory function to create the correct variable type based on the arguments."""
# Legacy naming error
if kwargs.get("variable"):
kwargs["base_var"] = kwargs.pop("variable")
try:
var_type = kwargs.pop("type")
# Overwrite dynamic attribute
if var_type in ["native_dynamic", "derived_dynamic"]:
kwargs["dynamic"] = True
except KeyError:
logger.error("local_datasource VariableLoader requires the 'type' field.")
if var_type == "complex":
return ComplexVariable.from_time_window(*args, **kwargs)
if var_type == "native_dynamic":
return NativeDynamic.from_time_window(*args, **kwargs)
if var_type == "derived_dynamic":
return DerivedDynamic.from_time_window(*args, **kwargs)
if var_type == "native_static":
return NativeStatic.from_time_window(*args, **kwargs)
if var_type == "derived_static":
return DerivedStatic.from_time_window(*args, **kwargs)
if var_type == "free_days":
return FreeDaysVariable.from_time_window(*args, **kwargs)
raise KeyError(f"Type {var_type} does not exist.")
class _ScreenedRequirements:
"""Propagate a variable's ``screened_obs_level`` down to its requirements.
A source screens queries at the coarse key that exists in its store
(``patient`` / ``hospital_stay``) and joins the fine-grained observation id back
into the frame afterwards — the fine id is not in the DB. A *derived* concept
therefore has to pass its screening on to the variables it depends on, so the
widening reaches the leaf variable that actually hits the DB.
The mechanism reuses the existing requirement-override channel: seeding
``screened_obs_level`` into each requirement dict makes
:meth:`Variable._get_required_vars` forward it as a per-load override. Because the
dependency it loads is itself a screened variable, propagation continues
recursively without either core or this mixin knowing the depth of the tree.
Only an **explicitly chosen** screening propagates (``screened_obs_level`` is not
``None``); an unset screening seeds nothing, so each dependency keeps its own
default. A requirement that sets its own ``screened_obs_level`` is left untouched.
"""
screened_obs_level: ObsLevel | Literal["patient", "hospital_stay"] | None
requirements: dict[str, RequirementsDict]
def _screening_override_name(self) -> ObsLevelName | None:
level = self.screened_obs_level
if level is None:
return None
return level.lower_name if isinstance(level, ObsLevel) else level
def _get_required_vars(self, cohort: Cohort) -> None:
name = self._screening_override_name()
if name is not None:
# Rebuild with fresh requirement dicts rather than mutating in place: the
# requirement dicts can be shared with the loaded vars.json config, so an
# in-place setdefault would leak the screening across loads. A requirement
# that set its own screening is preserved.
self.requirements = {
alias: {
**req,
"screened_obs_level": req.get("screened_obs_level", name),
}
for alias, req in self.requirements.items()
}
super()._get_required_vars(cohort) # type: ignore[misc]
[docs]
class BaseDynamic(_ScreenedRequirements, BaseVariable, ABC):
"""Abstract base for all dynamic variables of this source.
Provides the shared extraction pipeline: optimised obs-level selection,
caching, attribute building, time-filtering, and cleaning. Subclasses
implement :meth:`_extract_from_db` to supply the raw data.
Args:
var_name: Variable name.
dynamic: Whether the variable is dynamic (time-series).
requires: Dependency variables loaded before extraction.
tmin: Minimum time anchor.
tmax: Maximum time anchor.
py: Optional transformation function applied after raw extraction.
py_ready_polars: Whether ``py`` accepts and returns polars DataFrames.
cleaning: Value-range cleaning rules.
allow_caching: Whether to cache raw data in the cohort's tmpdir.
screened_obs_level: Default observation level used to scope DB queries
when the time window is not bounded by hospital-stay bounds.
"""
def __init__(
self,
var_name: str,
dynamic: bool,
requires: RequirementsIterable = [],
tmin: TimeAnchorColumn | TimeAnchor | None = None,
tmax: TimeAnchorColumn | TimeAnchor | None = None,
py: VariableCallable | None = None,
py_ready_polars: bool = False,
cleaning: CleaningDict | None = None,
allow_caching: bool = True,
screened_obs_level: Literal["patient", "hospital_stay"] | None = None,
) -> None:
super().__init__(
var_name=var_name,
dynamic=dynamic,
requires=requires,
tmin=tmin,
tmax=tmax,
py=py,
py_ready_polars=py_ready_polars,
cleaning=cleaning,
)
self.allow_caching = allow_caching
# None means "no explicit screening": the query coalesces to HOSPITAL_STAY
# (see _optimise_screened_obs_level) but nothing is propagated to requirements.
self.screened_obs_level: (
Literal[ObsLevel.HOSPITAL_STAY, ObsLevel.PATIENT] | None
) = (
ObsLevel(screened_obs_level) # type: ignore[call-arg]
if screened_obs_level is not None
else None
)
def _optimise_screened_obs_level(
self, cohort: Cohort
) -> Literal[ObsLevel.HOSPITAL_STAY, ObsLevel.PATIENT]:
# Ignore screened_obs_level and used fixed value for ObsLevel.PATIENT
if cohort.obs_level == ObsLevel.PATIENT:
logger.debug(
__(
"[{var_name}] cohort is patient-level -> screening DB query at PATIENT",
var_name=self.var_name,
)
)
return ObsLevel.PATIENT
# Optimise search if bounded by smallest searchable level (hospital stay)
bounded_by_hospital_stay_level = utils.bounded_by_obs_level(
time_window=self.time_window,
cohort=cohort,
obs_level=ObsLevel.HOSPITAL_STAY,
)
# No explicit screening (None) falls back to a hospital-stay query.
fallback = self.screened_obs_level or ObsLevel.HOSPITAL_STAY
screened = (
ObsLevel.HOSPITAL_STAY if bounded_by_hospital_stay_level else fallback
)
logger.debug(
__(
"[{var_name}] time_window bounded by hospital-stay bounds: {bounded} "
"-> screening DB query at {screened} "
"(unbounded fallback screened_obs_level={fallback})",
var_name=self.var_name,
bounded=bounded_by_hospital_stay_level,
screened=screened.name,
fallback=fallback.name,
)
)
return screened
def _clean_data(self) -> None:
if self.data is None:
return
self.data = self.data.with_columns(
(pl.selectors.date() | pl.selectors.datetime()).clip(
UNBOUNDED_TMIN, UNBOUNDED_TMAX
)
)
if self.dynamic:
self.data = utils.build_attributes(self.data)
@property
def _cache_name(self) -> str:
"""File stem the extraction cache is filed under.
Two variables from the same source must not serve each other's parquet,
and neither may two contributors sharing a var_name — the members of a
concept group — so the stem carries both identities.
"""
if self.contributor_key == self.var_name:
return self.var_name
return f"{self.var_name}@{self.contributor_key}"
def _get_from_cache(self, cohort: Cohort, cache_key: str) -> pl.DataFrame | None:
load_path = cohort.tmpdir_manager.create_tmpdir_variable_path(
var_name=self._cache_name, extension=None
)
# Before loading, ensure that cacheinfo exists and all case_ids from cohort.obs are present
if not (
self.allow_caching
and os.path.exists(f"{load_path}:{cache_key}.parquet")
and os.path.exists(f"{load_path}:{cache_key}.cacheinfo")
):
return None
with open(f"{load_path}:{cache_key}.cacheinfo", "rb") as f:
cached_ids = pickle.load(f)
load_ids = set(cohort._obs.select(cache_key).unique().to_series().to_list())
# Return none if cohort.obs contains uncached load_ids
if not load_ids.issubset(cached_ids):
return None
self.data = pl.read_parquet(f"{load_path}:{cache_key}.parquet")
logger.info(
__(
"Loaded {var_name} from tmpdir ({rows} rows)",
var_name=self.var_name,
rows=len(self.data),
)
)
if cache_key in self.data.columns:
self.data = self.data.filter(pl.col(cache_key).is_in(load_ids))
logger.info(
__("Found {rows} rows matching cohort.obs", rows=len(self.data))
)
return self.data
def _write_to_cache(self, cohort: Cohort, cache_key: str) -> None:
if not self.allow_caching or self.data is None:
return
save_path = cohort.tmpdir_manager.create_tmpdir_variable_path(
var_name=f"{self._cache_name}:{cache_key}", extension=None
)
self.data.write_parquet(f"{save_path}.parquet")
saved_ids = cohort._obs.select(cache_key).unique().to_series().to_list()
if os.path.exists(f"{save_path}.cacheinfo"):
os.remove(f"{save_path}.cacheinfo")
# Write cacheinfo
with open(f"{save_path}.cacheinfo", "wb") as f:
pickle.dump(saved_ids, f)
logger.info(
__(
"Wrote {var_name} to tmpdir ({rows} rows)",
var_name=self.var_name,
rows=len(self.data),
)
)
[docs]
class NativeDynamic(BaseDynamic):
"""Store-backed dynamic variable with the full :class:`BaseDynamic` pipeline.
Wraps a :class:`NativeExtractor` to provide caching, time-filtering,
cleaning, and optional ``py`` post-processing.
The extractor is the only part that touches a backing store, and the
published :class:`NativeExtractor` raises :class:`NotImplementedError`. Bind
this source to a store by subclassing it and setting
:attr:`extractor_class`:
.. code-block:: python
class MyExtractor(NativeExtractor):
def extract(self, cohort, id_column):
... # query your store, return one row per record
NativeDynamic.extractor_class = MyExtractor
Args:
table: Table name in the backing store.
where: Row filter appended to the generated query.
value_dtype: Optional polars dtype name applied to the ``value`` column
after extraction (e.g. ``"Float64"``).
columns: Column alias/cast mapping for the query.
All other args are forwarded to :class:`BaseDynamic`.
Note:
Only use this class for defining custom variables. For CORR-specified
variables, use ``cohort.add_variable()`` directly.
"""
#: Extractor the variable builds to reach the backing store. Rebind it on
#: this class (or on a subclass) to plug in a deployment's own query.
extractor_class: type[NativeExtractor] = NativeExtractor
def __init__(
self,
var_name: str,
dynamic: bool,
requires: RequirementsIterable = [],
tmin: TimeAnchorColumn | TimeAnchor | None = None,
tmax: TimeAnchorColumn | TimeAnchor | None = None,
py: VariableCallable | None = None,
py_ready_polars: bool = False,
cleaning: CleaningDict | None = None,
allow_caching: bool = True,
screened_obs_level: Literal["patient", "hospital_stay"] | None = None,
table: str | None = None,
where: str | None = None,
value_dtype: str | None = None,
columns: dict[str, str | dict[str, str]] | None = None,
) -> None:
super().__init__(
var_name=var_name,
dynamic=dynamic,
requires=requires,
tmin=tmin,
tmax=tmax,
py=py,
py_ready_polars=py_ready_polars,
cleaning=cleaning,
allow_caching=allow_caching,
screened_obs_level=screened_obs_level,
)
# self.value_dtype = value_dtype
self.extractor = self.extractor_class(
var_name=var_name,
time_window=self.time_window,
table=table,
where=where,
columns=columns,
value_dtype=value_dtype,
)
[docs]
class ComplexVariable(BaseDynamic):
"""Variable extracted entirely by a custom ``py()`` function.
No database query is issued; the variable function receives ``required_vars``
and must return a ``pl.DataFrame``. Time-filtering is applied only when
the function adds ``case_tmin``/``case_tmax`` columns to the result.
Args:
var_name: Name of the variable.
dynamic: Whether the variable is dynamic.
requires: Variables loaded and passed to the function.
tmin: Minimum time anchor.
tmax: Maximum time anchor.
py: Variable function that produces the data.
py_ready_polars: Whether ``py`` accepts and returns polars DataFrames.
cleaning: Dictionary of cleaning rules.
allow_caching: Whether to allow caching.
screened_obs_level: Observation level used for scoping the cache key.
"""
def __init__(
self,
var_name: str,
dynamic: bool,
requires: RequirementsIterable = [],
tmin: TimeAnchorColumn | TimeAnchor | None = None,
tmax: TimeAnchorColumn | TimeAnchor | None = None,
py: VariableCallable | None = None,
py_ready_polars: bool = False,
cleaning: CleaningDict | None = None,
allow_caching: bool = True,
screened_obs_level: Literal["patient", "hospital_stay"] | None = None,
) -> None:
super().__init__(
var_name=var_name,
dynamic=dynamic,
requires=requires,
tmin=tmin,
tmax=tmax,
py=py,
py_ready_polars=py_ready_polars,
cleaning=cleaning,
allow_caching=allow_caching,
screened_obs_level=screened_obs_level,
)
self._timefilter_always = False
[docs]
class NativeStatic(_ScreenedRequirements, BaseVariable):
"""Aggregated variables represent simple aggregations of dynamic variables.
Args:
var_name (str): Name of the variable.
select (str): Select clause specifying aggregation function and columns.
base_var (str): Name of the base variable (must be a native_dynamic variable).
where (str, optional): Optional WHERE clause (in format for polars).
tmin (str, optional): Minimum time for the extraction.
tmax (str, optional): Maximum time for the extraction.
The select argument supports several aggregation functions:
- ``!first [columns]``: Returns the first row within this case
>>> "!first value" # Single column
>>> "!first value, recordtime" # Multiple columns
- ``!last [columns]``: Returns the last row within this case
>>> "!last value"
>>> "!last value, recordtime"
- ``!any``: Returns True if any value exists
>>> "!any"
>>> "!any value"
- ``!sum [column]``: Calculates sum of values
>>> "!sum value"
- ``!closest(to_column, timedelta, plusminus) [columns]``: Selects value closest to specified column
Args:
to_column: Column to compare "recordtime" against
timedelta: Time to add to "to_column" for comparison
plusminus: Allowed time mismatch (can specify different before/after with space)
>>> "!closest(hospital_admission) value, recordtime" # Closest to admission
>>> "!closest(hospital_admission, 0, 2h 3h) value" # 2h before to 3h after
>>> "!closest(first_intubation_dtime, 6h, 2h) value" # 6h after intubation ±2h
- ``!mean [column]``: Calculates mean value
>>> "!mean value"
- ``!median [column]``: Calculates median value
>>> "!median value"
- ``!perc(quantile) [column]``: Calculates specified percentile
>>> "!perc(75) value" # 75th percentile
The where argument supports SQL-style boolean expressions. These are evaluated in the context of the base variable by column_selector().
Where also supports magic commands (starting with !) to filter the data. Supported commands are:
- ``!isin(column, [values])``: Filters rows where the value in column is in values
- ``!startswith(column, [values])``: Filters rows where the value in column starts with any of the values
- ``!endswith(column, [values])``: Filters rows where the value in column ends with any of the values
"""
base_var: tuple[str, TimeWindow] | VariableProtocol | MultiSourceVariable
def __init__(
self,
var_name: str,
select: str,
base_var: str | VariableProtocol | MultiSourceVariable,
where: str | None = None,
tmin: TimeAnchorColumn | TimeAnchor | None = None,
tmax: TimeAnchorColumn | TimeAnchor | None = None,
cleaning: CleaningDict | None = None,
screened_obs_level: Literal["patient", "hospital_stay"] | None = None,
) -> None:
super().__init__(
var_name,
dynamic=False,
tmin=tmin,
tmax=tmax,
cleaning=cleaning,
)
# Propagated to the wrapped base_var in extract() (NativeStatic loads its
# dependency via base_var rather than the requires mechanism).
self.screened_obs_level = screened_obs_level
logger.debug(
__(
"NativeStatic: SELECT {select} FROM {base_var} WHERE {where}",
select=select,
base_var=(
base_var
if isinstance(base_var, str)
else base_var.__class__.__name__
),
where=where,
)
)
self.select = select
self.where = where
if isinstance(base_var, str):
_var = (base_var, self.time_window)
else:
_var = base_var
_var.time_window = self.time_window
self.base_var = _var
self.agg_func, self.agg_params, self.select_cols = helpers.parse_select(
self.select
)
if len(self.select_cols) == 0:
self.select_cols = ["value"]
def _extract_aggregation(self, cohort: Cohort) -> pl.DataFrame:
"""Extract static variable using the base variable
Args:
cohort (Cohort): Cohort object.
Returns:
pl.DataFrame: Extracted variable.
"""
base_data = self.base_data.lazy()
obs = cohort._obs.lazy()
# Sort by recordtime
base_data = base_data.sort("recordtime")
# Apply where clause filtering
if self.where:
if self.where.startswith("!"):
where_func, where_params, _ = helpers.parse_select(self.where)
match where_func:
case "isin":
base_data = base_data.filter(
column_selector(where_params[0]).is_in(where_params[1])
)
case "startswith":
# Create OR expression for multiple prefixes
if len(where_params[1]) == 1:
base_data = base_data.filter(
column_selector(where_params[0]).str.starts_with(
where_params[1][0]
)
)
else:
starts_expr = column_selector(
where_params[0]
).str.starts_with(where_params[1][0])
for prefix in where_params[1][1:]:
starts_expr = starts_expr | column_selector(
where_params[0]
).str.starts_with(prefix)
base_data = base_data.filter(starts_expr)
case "endswith":
# Create OR expression for multiple suffixes
if len(where_params[1]) == 1:
base_data = base_data.filter(
column_selector(where_params[0]).str.ends_with(
where_params[1][0]
)
)
else:
ends_expr = column_selector(where_params[0]).str.ends_with(
where_params[1][0]
)
for suffix in where_params[1][1:]:
ends_expr = ends_expr | column_selector(
where_params[0]
).str.ends_with(suffix)
base_data = base_data.filter(ends_expr)
case _:
raise ValueError(
f"Unsupported where function: {where_func}. Supported functions are: isin, startswith, endswith"
)
else:
# Convert pandas-style where clause to polars expression
polars_where = pl.sql_expr(self.where)
base_data = base_data.filter(polars_where)
# Parse time arguments
obs = utils.add_time_window_expr(
obs, time_window=self.time_window, aliases=("tmin", "tmax")
)
# Join with observation times
base_data = base_data.join(
obs.select([cohort.primary_key, "tmin", "tmax"]),
on=cohort.primary_key,
how="left",
)
logger.debug(
__(
"Base data shape (before filtering): {rows}",
rows=utils.row_length(base_data),
)
)
# TODO: Use filter_with_time_anchor and remove filled_tmin/tmax_expr from add_time_window_expr
base_data_time = base_data.filter(
pl.col("recordtime").is_between("tmin", "tmax")
)
logger.debug(
__(
"Base data shape (after filtering tmin and tmax): {rows}",
rows=utils.row_length(base_data),
)
)
params = self.agg_params
# Perform aggregation using Polars syntax
match self.agg_func.lower():
case "first":
res = base_data_time.group_by(cohort.primary_key).agg(
column_selector(col).first() for col in self.select_cols
)
case "last":
res = base_data_time.group_by(cohort.primary_key).agg(
column_selector(col).last() for col in self.select_cols
)
case "mean":
res = base_data_time.group_by(cohort.primary_key).agg(
column_selector(col).mean() for col in self.select_cols
)
case "median":
res = base_data_time.group_by(cohort.primary_key).agg(
column_selector(col).median() for col in self.select_cols
)
case "min":
res = base_data_time.group_by(cohort.primary_key).agg(
column_selector(col).min() for col in self.select_cols
)
case "max":
res = base_data_time.group_by(cohort.primary_key).agg(
column_selector(col).max() for col in self.select_cols
)
case "std":
res = base_data_time.group_by(cohort.primary_key).agg(
column_selector(col).std() for col in self.select_cols
)
case "perc":
quantile = float(params[0]) / 100
res = base_data_time.group_by(cohort.primary_key).agg(
column_selector(col).quantile(quantile) for col in self.select_cols
)
case "sum":
res = base_data_time.group_by(cohort.primary_key).agg(
column_selector(col).sum() for col in self.select_cols
)
case "count":
res = base_data_time.group_by(cohort.primary_key).agg(
pl.len().alias(self.select_cols[0])
)
case "any":
res = base_data_time.group_by(cohort.primary_key).agg(
column_selector(col).is_not_null().any() for col in self.select_cols
)
# Right join with all observations to include cases with no data
res = (
obs.select(cohort.primary_key)
.unique()
.join(res, on=cohort.primary_key, how="left")
.with_columns(
column_selector(col).fill_null(False)
for col in self.select_cols
)
)
case "sum_interval":
# Use unfiltered base_data instead of base_data_time here
if "recordtime_end" not in base_data.columns:
raise ValueError(
"sum_interval requires 'recordtime_end' column in the base variable data"
)
interval_data = (
base_data.with_columns(
[
# Ensure recordtime_end is not null, use recordtime as fallback
pl.when(pl.col("recordtime_end").is_null())
.then(pl.col("recordtime"))
.otherwise(pl.col("recordtime_end"))
.alias("recordtime_end_clean"),
]
)
.with_columns(
[
# Ensure interval is valid (end >= start), swap if necessary
pl.when(
pl.col("recordtime_end_clean") < pl.col("recordtime")
)
.then(
pl.col("recordtime")
) # Use recordtime as both start and end if invalid
.otherwise(pl.col("recordtime_end_clean"))
.alias("recordtime_end_clean"),
]
)
.with_columns(
[
# Calculate overlap start (max of interval start and tmin)
pl.max_horizontal(["recordtime", "tmin"]).alias(
"overlap_start"
),
# Calculate overlap end (min of interval end and tmax)
pl.min_horizontal(["recordtime_end_clean", "tmax"]).alias(
"overlap_end"
),
]
)
)
# Filter to only intervals that have some overlap
interval_data = interval_data.filter(
pl.col("overlap_start") <= pl.col("overlap_end")
)
# Calculate overlap duration and total interval duration
interval_data = interval_data.with_columns(
[
# Overlap duration in seconds
time_difference("overlap_end", "overlap_start").alias(
"overlap_seconds"
),
# Total interval duration in seconds
time_difference("recordtime_end_clean", "recordtime").alias(
"interval_seconds"
),
]
)
# Calculate proportional values for each selected column
proportional_exprs = []
for col in self.select_cols:
# Handle division by zero: if interval is instantaneous (0 duration), use full value
proportional_expr = (
pl.when(pl.col("interval_seconds") <= 0)
.then(pl.col(col)) # Full value for instantaneous intervals
.otherwise(
pl.col(col)
* pl.col("overlap_seconds")
/ pl.col("interval_seconds")
)
.alias(f"{col}_proportional")
)
proportional_exprs.append(proportional_expr)
interval_data = interval_data.with_columns(proportional_exprs)
# Sum the proportional values by primary key
sum_exprs = [
pl.col(f"{col}_proportional").sum().alias(col)
for col in self.select_cols
]
res = interval_data.group_by(cohort.primary_key).agg(sum_exprs)
# Ensure all cases are included, even those with no overlapping intervals
# Join with all observations to include cases with zero sum
res = (
obs.select(cohort.primary_key)
.unique()
.join(res, on=cohort.primary_key, how="left")
.with_columns(
[pl.col(col).fill_null(0.0) for col in self.select_cols]
)
)
logger.debug(
__(
"sum_interval processed {rows} interval records",
rows=lambda: row_length(interval_data),
)
)
case "closest":
to_col = params[0]
tdelta = params[1] if len(params) > 1 else None
plusminus = params[2] if len(params) > 2 else "52w"
if " " in plusminus:
pm_before, pm_after = plusminus.split(" ")
else:
pm_before = pm_after = plusminus
# Convert time deltas to total seconds for tolerance
pm_before_secs = pd.to_timedelta(pm_before).total_seconds()
pm_after_secs = pd.to_timedelta(pm_after).total_seconds()
tdelta_secs = pd.to_timedelta(tdelta).total_seconds() if tdelta else 0.0
# Create target times dataframe
target_times = obs.select(
cohort.primary_key,
column_selector(to_col)
.add(pl.duration(seconds=int(tdelta_secs)))
.alias("target_time"),
)
# Use merge_asof for efficient time-based matching
# For asymmetric tolerances, we need to handle this differently
if pm_before_secs == pm_after_secs:
# Symmetric tolerance - use merge_asof directly
res = target_times.join_asof(
base_data_time.select(
[cohort.primary_key, "recordtime"] + self.select_cols
),
left_on="target_time",
right_on="recordtime",
by=cohort.primary_key,
tolerance=str(int(pm_before_secs)) + "s",
strategy="nearest",
).drop("target_time")
else:
# Asymmetric tolerance - filter then use merge_asof
# Create time bounds
target_times = target_times.with_columns(
pl.col("target_time")
.sub(pl.duration(seconds=int(pm_before_secs)))
.alias("time_min"),
pl.col("target_time")
.add(pl.duration(seconds=int(pm_after_secs)))
.alias("time_max"),
)
# Filter data within the asymmetric window using a join
filtered_data = base_data_time.join(
target_times.select(
cohort.primary_key, "time_min", "time_max", "target_time"
),
on=cohort.primary_key,
how="inner",
).filter(
pl.col("recordtime").ge(pl.col("time_min"))
& pl.col("recordtime").le(pl.col("time_max"))
)
# Now use merge_asof to find the closest match within the filtered data
res = target_times.join_asof(
filtered_data.select(
[cohort.primary_key, "recordtime"] + self.select_cols
),
left_on="target_time",
right_on="recordtime",
by=cohort.primary_key,
strategy="nearest",
).drop("target_time", "time_min", "time_max")
case _:
raise ValueError(f"Unsupported aggregation function: {self.agg_func}")
logger.info(
__(
"Extracted {var_name}.\nColumns: {columns}",
var_name=self.var_name,
columns=lambda: ", ".join(utils.columns(res)),
)
)
res = res.select([cohort.primary_key] + self.select_cols)
# Apply column naming
# TODO: Remove if this is already handled by Cohort._save_variable
if len(self.select_cols) > 1:
rename_map = {col: f"{self.var_name}_{col}" for col in self.select_cols}
res = res.rename(rename_map)
else:
res = res.rename({self.select_cols[0]: self.var_name})
return res.collect()
[docs]
class DerivedStatic(_ScreenedRequirements, BaseVariable):
"""DerivedStatic: These variables are derivations on existing columns in the cohort.obs dataframe based on the expression argument.
Args:
var_name: Name of the variable.
requires: List of required variables.
expression: Expression to extract the variable.
tmin: Minimum time for the extraction.
tmax: Maximum time for the extraction.
Note that DerivedStatic variables are executed on the cohort.obs dataframe and must reference existing columns in cohort.obs.
For DerivedStatic variables, you may either provide an SQL-Like expression (which will be parsed by `column_selector`) or a custom function in variables.py.
Use expressions where possible, but custom functions if you require more complex logic.
Examples:
>>> DerivedStatic(
... var_name="inhospital_death",
... requires=["hospital_discharge", "death_timestamp"],
... expression="hospital_discharge <= death_timestamp",
... )
>>> DerivedStatic(
... var_name="any_va_ecmo_icu",
... requires=["ecmo_va_icu_ops", "ecmo_va_icu"],
... expression=(ecmo_va_icu_ops | ecmo_va_icu),
... )
"""
def __init__(
self,
var_name,
requires: RequirementsIterable = [],
expression: str | None = None,
tmin: TimeAnchorColumn | TimeAnchor | None = None,
tmax: TimeAnchorColumn | TimeAnchor | None = None,
py: VariableCallable | None = None,
py_ready_polars: bool = False,
dynamic: bool = False,
cleaning: CleaningDict | None = None,
screened_obs_level: Literal["patient", "hospital_stay"] | None = None,
) -> None:
assert (
dynamic is False
), "DerivedStatic cannot be dynamic, please verify the configuration."
super().__init__(
var_name=var_name,
dynamic=False,
requires=requires,
tmin=tmin,
tmax=tmax,
py=py,
py_ready_polars=py_ready_polars,
cleaning=cleaning,
)
# Carried for propagation to requirements (see _ScreenedRequirements).
self.screened_obs_level = screened_obs_level
self.expression = expression
self.computed_expression = False
def _compute_expression(self, cohort: Cohort):
"""Compute the expression using the required variables.
Args:
cohort (Cohort): Cohort object.
Returns:
pl.DataFrame: Extracted variable. (Returns the original obs dataframe if no expression is provided.)
"""
obs = cohort._obs.clone()
for var in self.required_vars.values():
if not var.dynamic and var.data is not None:
obs = obs.join(
var.data.select([cohort.primary_key, var.var_name]),
on=cohort.primary_key,
how="left",
)
if not self.expression:
return obs
expr = pl.sql_expr(self.expression)
obs = obs.with_columns(expr.alias(self.var_name))
self.computed_expression = True
return obs
[docs]
class DerivedDynamic(_ScreenedRequirements, BaseVariable):
"""Derived dynamic variables are extracted using a custom function.
Args:
var_name: Name of the variable.
requires: List of required variables.
cleaning: Cleaning parameters ({column_name: {low: int, high: int}})
tmin: Minimum time for the extraction.
tmax: Maximum time for the extraction.
py: Custom function.
py_ready_polars: Whether the custom function is already prepared for polars. Default is False (input and output are pandas dataframes).
Examples:
>>> def var_func(var, cohort):
... # Note that this simplified example only works if blood_pao2_arterial and vent_fio2 are of the same length (which is proabably not the case).
... return var.with_columns(
... (
... var.required_vars["blood_pao2_arterial"]
... / var.required_vars["vent_fio2"]
... ).alias("pf_ratio")
... )
>>> DerivedDynamic(
... var_name="pf_ratio",
... requires=["blood_pao2_arterial", "vent_fio2"],
... py=var_func,
... py_ready_polars=True,
... )
"""
def __init__(
self,
var_name,
requires: RequirementsIterable,
cleaning: CleaningDict | None = None,
tmin: TimeAnchorColumn | TimeAnchor | None = None,
tmax: TimeAnchorColumn | TimeAnchor | None = None,
py: VariableCallable | None = None,
py_ready_polars: bool = False,
dynamic: bool = True,
screened_obs_level: Literal["patient", "hospital_stay"] | None = None,
) -> None:
assert (
dynamic is True
), "DerivedDynamic must be dynamic, please verify the configuration."
super().__init__(
var_name,
dynamic=True,
requires=requires,
tmin=tmin,
tmax=tmax,
py=py,
py_ready_polars=py_ready_polars,
cleaning=cleaning,
)
# Carried for propagation to requirements (see _ScreenedRequirements); a
# derived variable does not query the DB itself.
self.screened_obs_level = screened_obs_level