From 5953bf9d8ef88dd252c1515047cd2e6fd20e64cf Mon Sep 17 00:00:00 2001 From: eroell Date: Tue, 10 Dec 2024 20:58:50 +0100 Subject: [PATCH 01/18] towards first ehrdataloader --- src/ehrdata/__init__.py | 4 +- src/ehrdata/io/omop/_queries.py | 150 ++++++++++++++++++++++++++++++-- src/ehrdata/io/omop/omop.py | 14 ++- src/ehrdata/tl/__init__.py | 1 + src/ehrdata/tl/omop/__init__.py | 1 + src/ehrdata/tl/omop/_dataset.py | 69 +++++++++++++++ 6 files changed, 231 insertions(+), 8 deletions(-) create mode 100644 src/ehrdata/tl/__init__.py create mode 100644 src/ehrdata/tl/omop/__init__.py create mode 100644 src/ehrdata/tl/omop/_dataset.py diff --git a/src/ehrdata/__init__.py b/src/ehrdata/__init__.py index eb69f06..fb7769b 100644 --- a/src/ehrdata/__init__.py +++ b/src/ehrdata/__init__.py @@ -1,8 +1,8 @@ from importlib.metadata import version -from . import dt, io, pl +from . import dt, io, pl, tl from .core import EHRData -__all__ = ["EHRData", "dt", "io", "pl"] +__all__ = ["EHRData", "dt", "io", "tl", "pl"] __version__ = version("ehrdata") diff --git a/src/ehrdata/io/omop/_queries.py b/src/ehrdata/io/omop/_queries.py index f1937c5..6479622 100644 --- a/src/ehrdata/io/omop/_queries.py +++ b/src/ehrdata/io/omop/_queries.py @@ -125,7 +125,8 @@ def _time_interval_table( aggregation_strategy: str, data_field_to_keep: Sequence[str] | str, keep_date: str = "", -): + return_as_df: bool = False, +) -> pd.DataFrame | None: if isinstance(data_field_to_keep, str): data_field_to_keep = [data_field_to_keep] @@ -139,6 +140,8 @@ def _time_interval_table( timedeltas_dataframe, ) + create_long_table_query = "CREATE TABLE long_person_timestamp_feature_value AS\n" + # multi-step query # 1. Create person_time_defining_table, which matches the one created for obs. Needs to contain the person_id, and the start date in particular. # 2. Create person_data_table (data_table is typically measurement), which contains the cross product of person_id and the distinct concept_id s. @@ -196,10 +199,147 @@ def _time_interval_table( GROUP BY lfi.person_id, lfi.data_table_concept_id, interval_step, interval_start, interval_end """ - query = prepare_alias_query + select_query + query = create_long_table_query + prepare_alias_query + select_query + + backend_handle.execute("DROP TABLE IF EXISTS long_person_timestamp_feature_value") + backend_handle.execute(query) + + add_person_range_index_query = """ + ALTER TABLE long_person_timestamp_feature_value + ADD COLUMN person_index INTEGER; + + WITH RankedPersons AS ( + SELECT person_id, + ROW_NUMBER() OVER (ORDER BY person_id) - 1 AS idx + FROM (SELECT DISTINCT person_id FROM long_person_timestamp_feature_value) AS unique_persons + ) + UPDATE long_person_timestamp_feature_value + SET person_index = RP.idx + FROM RankedPersons RP + WHERE long_person_timestamp_feature_value.person_id = RP.person_id; + """ + backend_handle.execute(add_person_range_index_query) + + if return_as_df: + return backend_handle.execute("SELECT * FROM long_person_timestamp_feature_value").df() + else: + return None + + +# def _get_time_interval_table( +# backend_handle: duckdb.duckdb.DuckDBPyConnection, +# time_defining_table: str, +# data_table: str, +# interval_length_number: int, +# interval_length_unit: str, +# num_intervals: int, +# aggregation_strategy: str, +# data_field_to_keep: Sequence[str] | str, +# keep_date: str = "", +# ): +# return backend_handle.execute("SELECT * FROM long_person_timestamp_feature_value").df() + + +# def _time_interval_table_for_dataloader( +# backend_handle: duckdb.duckdb.DuckDBPyConnection, +# time_defining_table: str, +# data_table: str, +# interval_length_number: int, +# interval_length_unit: str, +# num_intervals: int, +# aggregation_strategy: str, +# data_field_to_keep: Sequence[str] | str, +# keep_date: str = "", +# ): +# if isinstance(data_field_to_keep, str): +# data_field_to_keep = [data_field_to_keep] + +# if keep_date == "": +# keep_date = "timepoint" + +# timedeltas_dataframe = _generate_timedeltas(interval_length_number, interval_length_unit, num_intervals) + +# _write_timedeltas_to_db( +# backend_handle, +# timedeltas_dataframe, +# ) + +# # multi-step query +# # 1. Create person_time_defining_table, which matches the one created for obs. Needs to contain the person_id, and the start date in particular. +# # 2. Create person_data_table (data_table is typically measurement), which contains the cross product of person_id and the distinct concept_id s. +# # 3. Create long_format_backbone, which is the left join of person_time_defining_table and person_data_table. +# # 4. Create long_format_intervals, which is the cross product of long_format_backbone and timedeltas. This table contains most notably the person_id, the concept_id, the interval start and end dates. +# # 5. Create the final table, which is the join with the data_table (typically measurement); each measurement is assigned to its person_id, its concept_id, and the interval it fits into. +# prepare_alias_query = f""" +# CREATE TABLE long_person_timestamp_feature_value AS \ +# WITH person_time_defining_table AS ( \ +# SELECT person.person_id as person_id, {DATA_TABLE_DATE_KEYS["start"][time_defining_table]} as start_date, {DATA_TABLE_DATE_KEYS["end"][time_defining_table]} as end_date \ +# FROM person \ +# JOIN {time_defining_table} ON person.person_id = {time_defining_table}.{TIME_DEFINING_TABLE_SUBJECT_KEY[time_defining_table]} \ +# WHERE visit_concept_id = 262 \ +# ), \ +# person_data_table AS( \ +# WITH distinct_data_table_concept_ids AS ( \ +# SELECT DISTINCT {DATA_TABLE_CONCEPT_ID_TRUNK[data_table]}_concept_id +# FROM {data_table} \ +# ) +# SELECT person.person_id, {DATA_TABLE_CONCEPT_ID_TRUNK[data_table]}_concept_id as data_table_concept_id \ +# FROM person \ +# CROSS JOIN distinct_data_table_concept_ids \ +# ), \ +# long_format_backbone as ( \ +# SELECT person_time_defining_table.person_id, data_table_concept_id, start_date, end_date \ +# FROM person_time_defining_table \ +# LEFT JOIN person_data_table USING(person_id)\ +# ), \ +# long_format_intervals as ( \ +# SELECT person_id, data_table_concept_id, interval_step, start_date, start_date + interval_start_offset as interval_start, start_date + interval_end_offset as interval_end \ +# FROM long_format_backbone \ +# CROSS JOIN timedeltas \ +# ), \ +# data_table_with_presence_indicator as( \ +# SELECT *, 1 as is_present \ +# FROM {data_table} \ +# ) \ +# """ + +# if keep_date in ["timepoint", "start", "end"]: +# select_query = f""" +# SELECT lfi.person_id, lfi.data_table_concept_id, interval_step, interval_start, interval_end, {_generate_value_query("data_table_with_presence_indicator", data_field_to_keep, AGGREGATION_STRATEGY_KEY[aggregation_strategy])} \ +# FROM long_format_intervals as lfi \ +# LEFT JOIN data_table_with_presence_indicator ON lfi.person_id = data_table_with_presence_indicator.person_id AND lfi.data_table_concept_id = data_table_with_presence_indicator.{DATA_TABLE_CONCEPT_ID_TRUNK[data_table]}_concept_id AND data_table_with_presence_indicator.{DATA_TABLE_DATE_KEYS[keep_date][data_table]} BETWEEN lfi.interval_start AND lfi.interval_end \ +# GROUP BY lfi.person_id, lfi.data_table_concept_id, interval_step, interval_start, interval_end +# """ + +# elif keep_date == "interval": +# select_query = f""" +# SELECT lfi.person_id, lfi.data_table_concept_id, interval_step, interval_start, interval_end, {_generate_value_query("data_table_with_presence_indicator", data_field_to_keep, AGGREGATION_STRATEGY_KEY[aggregation_strategy])} \ +# FROM long_format_intervals as lfi \ +# LEFT JOIN data_table_with_presence_indicator ON lfi.person_id = data_table_with_presence_indicator.person_id \ +# AND lfi.data_table_concept_id = data_table_with_presence_indicator.{DATA_TABLE_CONCEPT_ID_TRUNK[data_table]}_concept_id \ +# AND (data_table_with_presence_indicator.{DATA_TABLE_DATE_KEYS["start"][data_table]} BETWEEN lfi.interval_start AND lfi.interval_end \ +# OR data_table_with_presence_indicator.{DATA_TABLE_DATE_KEYS["end"][data_table]} BETWEEN lfi.interval_start AND lfi.interval_end \ +# OR (data_table_with_presence_indicator.{DATA_TABLE_DATE_KEYS["start"][data_table]} < lfi.interval_start AND data_table_with_presence_indicator.{DATA_TABLE_DATE_KEYS["end"][data_table]} > lfi.interval_end)) \ +# GROUP BY lfi.person_id, lfi.data_table_concept_id, interval_step, interval_start, interval_end +# """ - df = backend_handle.execute(query).df() +# query = prepare_alias_query + select_query +# backend_handle.execute("DROP TABLE IF EXISTS long_person_timestamp_feature_value") +# backend_handle.execute(query) +# add_person_range_index_query = """ +# ALTER TABLE long_person_timestamp_feature_value +# ADD COLUMN person_index INTEGER; - _drop_timedeltas(backend_handle) +# WITH RankedPersons AS ( +# SELECT person_id, +# ROW_NUMBER() OVER (ORDER BY person_id) - 1 AS idx +# FROM (SELECT DISTINCT person_id FROM long_person_timestamp_feature_value) AS unique_persons +# ) +# UPDATE long_person_timestamp_feature_value +# SET person_index = RP.idx +# FROM RankedPersons RP +# WHERE long_person_timestamp_feature_value.person_id = RP.person_id; +# """ +# backend_handle.execute(add_person_range_index_query) - return df +# return None diff --git a/src/ehrdata/io/omop/omop.py b/src/ehrdata/io/omop/omop.py index 4561fca..273b475 100644 --- a/src/ehrdata/io/omop/omop.py +++ b/src/ehrdata/io/omop/omop.py @@ -111,7 +111,7 @@ def _check_one_unit_per_feature(ds, unit_key="unit_concept_id") -> None: num_units = np.array([len(units) for _, units in feature_units.items()]) # print(f"no units for features: {np.argwhere(num_units == 0)}") - print(f"multiple units for features: {np.argwhere(num_units > 1)}") + logging.warning(f"multiple units for features: {np.argwhere(num_units > 1)}") def _create_feature_unit_concept_id_report(backend_handle, ds) -> pd.DataFrame: @@ -257,12 +257,18 @@ def setup_variables( aggregation_strategy: str = "last", enrich_var_with_feature_info: bool = False, enrich_var_with_unit_info: bool = False, + instantiate_tensor: bool = True, ): """Setup the variables. This function sets up the variables for the EHRData object. It will fail if there is more than one unit_concept_id per feature. Writes a unit report of the features to edata.uns["unit_report_"]. + Writes the setup arguments into edata.uns["omop_io_variable_setup"]. + + Stores a table named `long_person_timestamp_feature_value` in long format in the RDBMS. + This table is instantiated into edata.r if `instantiate_tensor` is set to True; + otherwise, the table is only stored in the RDBMS for later use. Parameters ---------- @@ -290,6 +296,8 @@ def setup_variables( Whether to enrich the var table with feature information. If a concept_id is not found in the concept table, the feature information will be NaN. enrich_var_with_unit_info Whether to enrich the var table with unit information. Raises an Error if a) multiple units per feature are found for at least one feature. If a concept_id is not found in the concept table, the feature information will be NaN. + instantiate_tensor + Whether to instantiate the tensor into the .r field of the EHRData object. Returns ------- @@ -331,6 +339,7 @@ def setup_variables( logging.warning(f"No data found in {data_tables[0]}. Returning edata without additional variables.") return edata + # TODO: if instantiate_tensor ds = ( _time_interval_table( backend_handle=backend_handle, @@ -341,11 +350,13 @@ def setup_variables( interval_length_unit=interval_length_unit, num_intervals=num_intervals, aggregation_strategy=aggregation_strategy, + return_as_df=True, ) .set_index(["person_id", "data_table_concept_id", "interval_step"]) .to_xarray() ) + # TODO: if instantiate_tensor! rdbms backed, make ds independent but build on long table _check_one_unit_per_feature(ds) # TODO ignore? go with more vanilla omop style. _check_one_unit_per_feature(ds, unit_key="unit_source_value") @@ -477,6 +488,7 @@ def setup_interval_variables( num_intervals=num_intervals, aggregation_strategy=aggregation_strategy, keep_date=keep_date, + return_as_df=True, ) .set_index(["person_id", "data_table_concept_id", "interval_step"]) .to_xarray() diff --git a/src/ehrdata/tl/__init__.py b/src/ehrdata/tl/__init__.py new file mode 100644 index 0000000..1c16543 --- /dev/null +++ b/src/ehrdata/tl/__init__.py @@ -0,0 +1 @@ +from . import omop diff --git a/src/ehrdata/tl/omop/__init__.py b/src/ehrdata/tl/omop/__init__.py new file mode 100644 index 0000000..72518bc --- /dev/null +++ b/src/ehrdata/tl/omop/__init__.py @@ -0,0 +1 @@ +from ._dataset import EHRDataSet diff --git a/src/ehrdata/tl/omop/_dataset.py b/src/ehrdata/tl/omop/_dataset.py new file mode 100644 index 0000000..0a41bfc --- /dev/null +++ b/src/ehrdata/tl/omop/_dataset.py @@ -0,0 +1,69 @@ +from collections.abc import Sequence + +import torch +from duckdb.duckdb import DuckDBPyConnection +from torch.utils.data import Dataset + + +class EHRDataSet(Dataset): + def __init__( + self, + con: DuckDBPyConnection, + n_variables: int, + n_timesteps: int, + batch_size: int = 10, + idxs: Sequence[int] | None = None, + ): + super().__init__() + self.con = con + self.batch_size = batch_size + self.idxs = idxs + + # TODO: get from database or EHRData? + self.n_timesteps = n_timesteps + self.n_variables = n_variables + + def __len__(self): + if self.idxs: + where_clause = f"WHERE person_id IN ({','.join(str(_) for _ in self.idxs)})" + else: + where_clause = "" + query = f""" + SELECT COUNT(DISTINCT person_id) + FROM long_person_timestamp_feature_value + {where_clause} + """ + return self.con.execute(query).fetchone()[0] # .item() + + def __getitem__(self, person_id): + # if isinstance(person_ids, int): + # person_ids = [person_ids] # Make it a list for consistent handling + # elif isinstance(person_ids, slice): + # person_ids = range(person_ids.start or 0, person_ids.stop, person_ids.step or 1) + + where_clause = f"WHERE person_index = {person_id}" + + if self.idxs: + where_clause += f" AND person_index IN ({','.join(str(_) for _ in self.idxs)})" + # else: + # where_clause = "" + + query = f""" + SELECT person_index, data_table_concept_id, interval_step, COALESCE(CAST(value_as_number AS DOUBLE), 'NaN') AS value_as_number + FROM long_person_timestamp_feature_value + {where_clause} + """ + # AND data_table_concept_id = {feature_id} + # AND interval_step = {timestep} + # data is fetched in long format + long_format_data = torch.tensor(self.con.execute(query).fetchall(), dtype=torch.float32) + + # convert long format to 3D tensor + # sample_ids, sample_idx = torch.unique(long_format_data[:, 0], return_inverse=True) + feature_ids, feature_idx = torch.unique(long_format_data[:, 1], return_inverse=True) + step_ids, step_idx = torch.unique(long_format_data[:, 2], return_inverse=True) + + result = torch.zeros(len(feature_ids), len(step_ids)) + values = long_format_data[:, 3] + result.index_put_((feature_idx, step_idx), values) + return result From c2e7f5d880a7845714e0f959033bf73cb2887efe Mon Sep 17 00:00:00 2001 From: eroell Date: Tue, 10 Dec 2024 21:21:44 +0100 Subject: [PATCH 02/18] use of escapechar % caused concept table L2231 to fail, too many columns --- src/ehrdata/io/omop/omop.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ehrdata/io/omop/omop.py b/src/ehrdata/io/omop/omop.py index 273b475..454f58f 100644 --- a/src/ehrdata/io/omop/omop.py +++ b/src/ehrdata/io/omop/omop.py @@ -65,7 +65,7 @@ def _set_up_duckdb(path: Path, backend_handle: DuckDBPyConnection, prefix: str = dtype = None # read raw csv as temporary table - temp_relation = backend_handle.read_csv(path / file_name, dtype=dtype, escapechar="%") # noqa: F841 + temp_relation = backend_handle.read_csv(path / file_name, dtype=dtype) # noqa: F841 backend_handle.execute("CREATE OR REPLACE TABLE temp_table AS SELECT * FROM temp_relation") # make query to create table with lowercase column names From 32589a9b586aae29dbace987d1852dc4401e472e Mon Sep 17 00:00:00 2001 From: eroell Date: Tue, 10 Dec 2024 22:54:44 +0100 Subject: [PATCH 03/18] refactor multiple unit to con instead of tensor; add test multiple unit warning --- src/ehrdata/io/omop/omop.py | 34 +++++++++++-------- tests/conftest.py | 14 ++++++-- .../toy_omop/multiple_units/observation.csv | 5 +++ .../multiple_units/observation_period.csv | 2 ++ tests/data/toy_omop/multiple_units/person.csv | 2 ++ tests/test_io/test_omop.py | 18 ++++++++++ 6 files changed, 58 insertions(+), 17 deletions(-) create mode 100644 tests/data/toy_omop/multiple_units/observation.csv create mode 100644 tests/data/toy_omop/multiple_units/observation_period.csv create mode 100644 tests/data/toy_omop/multiple_units/person.csv diff --git a/src/ehrdata/io/omop/omop.py b/src/ehrdata/io/omop/omop.py index 454f58f..9ab2c5a 100644 --- a/src/ehrdata/io/omop/omop.py +++ b/src/ehrdata/io/omop/omop.py @@ -96,26 +96,32 @@ def _set_up_duckdb(path: Path, backend_handle: DuckDBPyConnection, prefix: str = logging.info(f"unused files: {unused_files}") -def _collect_units_per_feature(ds, unit_key="unit_concept_id") -> dict: +def _collect_units_per_feature(backend_handle, unit_key="unit_concept_id") -> dict: + query = f""" + SELECT DISTINCT data_table_concept_id, {unit_key} FROM long_person_timestamp_feature_value + WHERE is_present = 1 + """ + result = backend_handle.execute(query).fetchall() + feature_units = {} - for i in range(ds[unit_key].shape[1]): - single_feature_units = ds[unit_key].isel({ds[unit_key].dims[1]: i}) - single_feature_units_flat = np.array(single_feature_units).flatten() - single_feature_units_unique = pd.unique(single_feature_units_flat[~pd.isna(single_feature_units_flat)]) - feature_units[ds["data_table_concept_id"][i].item()] = single_feature_units_unique + for feature, unit in result: + if feature in feature_units: + feature_units[feature].append(unit) + else: + feature_units[feature] = [unit] return feature_units -def _check_one_unit_per_feature(ds, unit_key="unit_concept_id") -> None: - feature_units = _collect_units_per_feature(ds, unit_key=unit_key) +def _check_one_unit_per_feature(backend_handle, unit_key="unit_concept_id") -> None: + feature_units = _collect_units_per_feature(backend_handle, unit_key=unit_key) num_units = np.array([len(units) for _, units in feature_units.items()]) # print(f"no units for features: {np.argwhere(num_units == 0)}") logging.warning(f"multiple units for features: {np.argwhere(num_units > 1)}") -def _create_feature_unit_concept_id_report(backend_handle, ds) -> pd.DataFrame: - feature_units_concept = _collect_units_per_feature(ds, unit_key="unit_concept_id") +def _create_feature_unit_concept_id_report(backend_handle) -> pd.DataFrame: + feature_units_concept = _collect_units_per_feature(backend_handle, unit_key="unit_concept_id") feature_units_long_format = [] for feature, units in feature_units_concept.items(): @@ -245,7 +251,7 @@ def setup_obs( def setup_variables( edata, - *, + # *, backend_handle: duckdb.duckdb.DuckDBPyConnection, data_tables: Sequence[Literal["measurement", "observation", "specimen"]] | Literal["measurement", "observation", "specimen"], @@ -295,7 +301,7 @@ def setup_variables( enrich_var_with_feature_info Whether to enrich the var table with feature information. If a concept_id is not found in the concept table, the feature information will be NaN. enrich_var_with_unit_info - Whether to enrich the var table with unit information. Raises an Error if a) multiple units per feature are found for at least one feature. If a concept_id is not found in the concept table, the feature information will be NaN. + Whether to enrich the var table with unit information. Raises an Error if multiple units per feature are found for at least one feature. For entire missing data points, the units are ignored. For observed data points with missing unit information (NULL in either unit_concept_id or unit_source_value), the value NULL/NaN is considered a single unit. instantiate_tensor Whether to instantiate the tensor into the .r field of the EHRData object. @@ -357,10 +363,10 @@ def setup_variables( ) # TODO: if instantiate_tensor! rdbms backed, make ds independent but build on long table - _check_one_unit_per_feature(ds) + _check_one_unit_per_feature(backend_handle) # TODO ignore? go with more vanilla omop style. _check_one_unit_per_feature(ds, unit_key="unit_source_value") - unit_report = _create_feature_unit_concept_id_report(backend_handle, ds) + unit_report = _create_feature_unit_concept_id_report(backend_handle) var = ds["data_table_concept_id"].to_dataframe() diff --git a/tests/conftest.py b/tests/conftest.py index a42fcb1..4930316 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,7 +4,7 @@ from ehrdata.io.omop import setup_connection -@pytest.fixture # (scope="session") +@pytest.fixture def omop_connection_vanilla(): con = duckdb.connect() setup_connection(path="tests/data/toy_omop/vanilla", backend_handle=con) @@ -12,7 +12,7 @@ def omop_connection_vanilla(): con.close() -@pytest.fixture # (scope="session") +@pytest.fixture def omop_connection_capital_letters(): con = duckdb.connect() setup_connection(path="tests/data/toy_omop/capital_letters", backend_handle=con) @@ -20,9 +20,17 @@ def omop_connection_capital_letters(): con.close() -@pytest.fixture # (scope="session") +@pytest.fixture def omop_connection_empty_observation(): con = duckdb.connect() setup_connection(path="tests/data/toy_omop/empty_observation", backend_handle=con) yield con con.close() + + +@pytest.fixture +def omop_connection_multiple_units(): + con = duckdb.connect() + setup_connection(path="tests/data/toy_omop/multiple_units", backend_handle=con) + yield con + con.close() diff --git a/tests/data/toy_omop/multiple_units/observation.csv b/tests/data/toy_omop/multiple_units/observation.csv new file mode 100644 index 0000000..82066fe --- /dev/null +++ b/tests/data/toy_omop/multiple_units/observation.csv @@ -0,0 +1,5 @@ +observation_id,person_id,observation_concept_id,observation_date,observation_datetime,observation_type_concept_id,value_as_number,value_as_string,value_as_concept_id,qualifier_concept_id,unit_concept_id,provider_id,visit_occurrence_id,visit_detail_id,observation_source_value,observation_source_concept_id,unit_source_value,qualifier_source_value +1,1,3001062,2100-01-01,2100-01-01 12:00:00,32817,,Anemia,0,,8587,,,,225059,2000030108,mL, +2,1,3001062,2100-01-02,2100-01-02 12:00:00,32817,,Anemia,0,,9665,,,,225059,2000030108,uL, +3,1,3034263,2100-01-01,2100-01-01 12:00:00,32817,5,,,,8587,,,,224409,2000030058,mL, +4,1,3034263,2100-01-02,2100-01-02 12:00:00,32817,5,,,,9665,,,,224409,2000030058,uL, diff --git a/tests/data/toy_omop/multiple_units/observation_period.csv b/tests/data/toy_omop/multiple_units/observation_period.csv new file mode 100644 index 0000000..40b7351 --- /dev/null +++ b/tests/data/toy_omop/multiple_units/observation_period.csv @@ -0,0 +1,2 @@ +observation_period_id,person_id,observation_period_start_date,observation_period_end_date,period_type_concept_id +1,1,2100-01-01,2100-01-31,32828 diff --git a/tests/data/toy_omop/multiple_units/person.csv b/tests/data/toy_omop/multiple_units/person.csv new file mode 100644 index 0000000..0f13db9 --- /dev/null +++ b/tests/data/toy_omop/multiple_units/person.csv @@ -0,0 +1,2 @@ +person_id,gender_concept_id,year_of_birth,month_of_birth,day_of_birth,birth_datetime,race_concept_id,ethnicity_concept_id,location_id,provider_id,care_site_id,person_source_value,gender_source_value,gender_source_concept_id,race_source_value,race_source_concept_id,ethnicity_source_value,ethnicity_source_concept_id +1,8507,2095,,,,0,38003563,,,,1234,M,0,,,, diff --git a/tests/test_io/test_omop.py b/tests/test_io/test_omop.py index ac426e7..0b42e51 100644 --- a/tests/test_io/test_omop.py +++ b/tests/test_io/test_omop.py @@ -821,3 +821,21 @@ def test_empty_observation(omop_connection_empty_observation, caplog): ) assert edata.shape == (1, 0) assert "No data found in observation. Returning edata without additional variables." in caplog.text + + +def test_multiple_units(omop_connection_multiple_units, caplog): + con = omop_connection_multiple_units + edata = ed.io.omop.setup_obs(backend_handle=con, observation_table="person_observation_period") + edata = ed.io.omop.setup_variables( + edata, + backend_handle=con, + data_tables=["observation"], + data_field_to_keep=["value_as_number"], + interval_length_number=1, + interval_length_unit="day", + num_intervals=2, + enrich_var_with_feature_info=False, + enrich_var_with_unit_info=False, + ) + # assert edata.shape == (1, 0) + assert "multiple units for features: [[0]\n [1]]\n" in caplog.text From a049fa6e7eb839878081c11a2c1c7f222784fc61 Mon Sep 17 00:00:00 2001 From: eroell Date: Tue, 10 Dec 2024 23:19:33 +0100 Subject: [PATCH 04/18] refactor time interval writing sql query --- src/ehrdata/io/omop/_queries.py | 129 +------------------------------- src/ehrdata/io/omop/omop.py | 78 ++++++++++--------- 2 files changed, 44 insertions(+), 163 deletions(-) diff --git a/src/ehrdata/io/omop/_queries.py b/src/ehrdata/io/omop/_queries.py index 6479622..552bd3c 100644 --- a/src/ehrdata/io/omop/_queries.py +++ b/src/ehrdata/io/omop/_queries.py @@ -115,7 +115,7 @@ def _generate_value_query(data_table: str, data_field_to_keep: Sequence, aggrega return is_present_query + value_query -def _time_interval_table( +def _write_long_time_interval_table( backend_handle: duckdb.duckdb.DuckDBPyConnection, time_defining_table: str, data_table: str, @@ -125,8 +125,7 @@ def _time_interval_table( aggregation_strategy: str, data_field_to_keep: Sequence[str] | str, keep_date: str = "", - return_as_df: bool = False, -) -> pd.DataFrame | None: +) -> None: if isinstance(data_field_to_keep, str): data_field_to_keep = [data_field_to_keep] @@ -219,127 +218,3 @@ def _time_interval_table( WHERE long_person_timestamp_feature_value.person_id = RP.person_id; """ backend_handle.execute(add_person_range_index_query) - - if return_as_df: - return backend_handle.execute("SELECT * FROM long_person_timestamp_feature_value").df() - else: - return None - - -# def _get_time_interval_table( -# backend_handle: duckdb.duckdb.DuckDBPyConnection, -# time_defining_table: str, -# data_table: str, -# interval_length_number: int, -# interval_length_unit: str, -# num_intervals: int, -# aggregation_strategy: str, -# data_field_to_keep: Sequence[str] | str, -# keep_date: str = "", -# ): -# return backend_handle.execute("SELECT * FROM long_person_timestamp_feature_value").df() - - -# def _time_interval_table_for_dataloader( -# backend_handle: duckdb.duckdb.DuckDBPyConnection, -# time_defining_table: str, -# data_table: str, -# interval_length_number: int, -# interval_length_unit: str, -# num_intervals: int, -# aggregation_strategy: str, -# data_field_to_keep: Sequence[str] | str, -# keep_date: str = "", -# ): -# if isinstance(data_field_to_keep, str): -# data_field_to_keep = [data_field_to_keep] - -# if keep_date == "": -# keep_date = "timepoint" - -# timedeltas_dataframe = _generate_timedeltas(interval_length_number, interval_length_unit, num_intervals) - -# _write_timedeltas_to_db( -# backend_handle, -# timedeltas_dataframe, -# ) - -# # multi-step query -# # 1. Create person_time_defining_table, which matches the one created for obs. Needs to contain the person_id, and the start date in particular. -# # 2. Create person_data_table (data_table is typically measurement), which contains the cross product of person_id and the distinct concept_id s. -# # 3. Create long_format_backbone, which is the left join of person_time_defining_table and person_data_table. -# # 4. Create long_format_intervals, which is the cross product of long_format_backbone and timedeltas. This table contains most notably the person_id, the concept_id, the interval start and end dates. -# # 5. Create the final table, which is the join with the data_table (typically measurement); each measurement is assigned to its person_id, its concept_id, and the interval it fits into. -# prepare_alias_query = f""" -# CREATE TABLE long_person_timestamp_feature_value AS \ -# WITH person_time_defining_table AS ( \ -# SELECT person.person_id as person_id, {DATA_TABLE_DATE_KEYS["start"][time_defining_table]} as start_date, {DATA_TABLE_DATE_KEYS["end"][time_defining_table]} as end_date \ -# FROM person \ -# JOIN {time_defining_table} ON person.person_id = {time_defining_table}.{TIME_DEFINING_TABLE_SUBJECT_KEY[time_defining_table]} \ -# WHERE visit_concept_id = 262 \ -# ), \ -# person_data_table AS( \ -# WITH distinct_data_table_concept_ids AS ( \ -# SELECT DISTINCT {DATA_TABLE_CONCEPT_ID_TRUNK[data_table]}_concept_id -# FROM {data_table} \ -# ) -# SELECT person.person_id, {DATA_TABLE_CONCEPT_ID_TRUNK[data_table]}_concept_id as data_table_concept_id \ -# FROM person \ -# CROSS JOIN distinct_data_table_concept_ids \ -# ), \ -# long_format_backbone as ( \ -# SELECT person_time_defining_table.person_id, data_table_concept_id, start_date, end_date \ -# FROM person_time_defining_table \ -# LEFT JOIN person_data_table USING(person_id)\ -# ), \ -# long_format_intervals as ( \ -# SELECT person_id, data_table_concept_id, interval_step, start_date, start_date + interval_start_offset as interval_start, start_date + interval_end_offset as interval_end \ -# FROM long_format_backbone \ -# CROSS JOIN timedeltas \ -# ), \ -# data_table_with_presence_indicator as( \ -# SELECT *, 1 as is_present \ -# FROM {data_table} \ -# ) \ -# """ - -# if keep_date in ["timepoint", "start", "end"]: -# select_query = f""" -# SELECT lfi.person_id, lfi.data_table_concept_id, interval_step, interval_start, interval_end, {_generate_value_query("data_table_with_presence_indicator", data_field_to_keep, AGGREGATION_STRATEGY_KEY[aggregation_strategy])} \ -# FROM long_format_intervals as lfi \ -# LEFT JOIN data_table_with_presence_indicator ON lfi.person_id = data_table_with_presence_indicator.person_id AND lfi.data_table_concept_id = data_table_with_presence_indicator.{DATA_TABLE_CONCEPT_ID_TRUNK[data_table]}_concept_id AND data_table_with_presence_indicator.{DATA_TABLE_DATE_KEYS[keep_date][data_table]} BETWEEN lfi.interval_start AND lfi.interval_end \ -# GROUP BY lfi.person_id, lfi.data_table_concept_id, interval_step, interval_start, interval_end -# """ - -# elif keep_date == "interval": -# select_query = f""" -# SELECT lfi.person_id, lfi.data_table_concept_id, interval_step, interval_start, interval_end, {_generate_value_query("data_table_with_presence_indicator", data_field_to_keep, AGGREGATION_STRATEGY_KEY[aggregation_strategy])} \ -# FROM long_format_intervals as lfi \ -# LEFT JOIN data_table_with_presence_indicator ON lfi.person_id = data_table_with_presence_indicator.person_id \ -# AND lfi.data_table_concept_id = data_table_with_presence_indicator.{DATA_TABLE_CONCEPT_ID_TRUNK[data_table]}_concept_id \ -# AND (data_table_with_presence_indicator.{DATA_TABLE_DATE_KEYS["start"][data_table]} BETWEEN lfi.interval_start AND lfi.interval_end \ -# OR data_table_with_presence_indicator.{DATA_TABLE_DATE_KEYS["end"][data_table]} BETWEEN lfi.interval_start AND lfi.interval_end \ -# OR (data_table_with_presence_indicator.{DATA_TABLE_DATE_KEYS["start"][data_table]} < lfi.interval_start AND data_table_with_presence_indicator.{DATA_TABLE_DATE_KEYS["end"][data_table]} > lfi.interval_end)) \ -# GROUP BY lfi.person_id, lfi.data_table_concept_id, interval_step, interval_start, interval_end -# """ - -# query = prepare_alias_query + select_query -# backend_handle.execute("DROP TABLE IF EXISTS long_person_timestamp_feature_value") -# backend_handle.execute(query) -# add_person_range_index_query = """ -# ALTER TABLE long_person_timestamp_feature_value -# ADD COLUMN person_index INTEGER; - -# WITH RankedPersons AS ( -# SELECT person_id, -# ROW_NUMBER() OVER (ORDER BY person_id) - 1 AS idx -# FROM (SELECT DISTINCT person_id FROM long_person_timestamp_feature_value) AS unique_persons -# ) -# UPDATE long_person_timestamp_feature_value -# SET person_index = RP.idx -# FROM RankedPersons RP -# WHERE long_person_timestamp_feature_value.person_id = RP.person_id; -# """ -# backend_handle.execute(add_person_range_index_query) - -# return None diff --git a/src/ehrdata/io/omop/omop.py b/src/ehrdata/io/omop/omop.py index 9ab2c5a..452bff6 100644 --- a/src/ehrdata/io/omop/omop.py +++ b/src/ehrdata/io/omop/omop.py @@ -32,7 +32,7 @@ _check_valid_observation_table, _check_valid_variable_data_tables, ) -from ehrdata.io.omop._queries import _time_interval_table +from ehrdata.io.omop._queries import _write_long_time_interval_table DOWNLOAD_VERIFICATION_TAG = "download_verification_tag" @@ -345,30 +345,21 @@ def setup_variables( logging.warning(f"No data found in {data_tables[0]}. Returning edata without additional variables.") return edata - # TODO: if instantiate_tensor - ds = ( - _time_interval_table( - backend_handle=backend_handle, - time_defining_table=time_defining_table, - data_table=data_tables[0], - data_field_to_keep=data_field_to_keep, - interval_length_number=interval_length_number, - interval_length_unit=interval_length_unit, - num_intervals=num_intervals, - aggregation_strategy=aggregation_strategy, - return_as_df=True, - ) - .set_index(["person_id", "data_table_concept_id", "interval_step"]) - .to_xarray() + _write_long_time_interval_table( + backend_handle=backend_handle, + time_defining_table=time_defining_table, + data_table=data_tables[0], + data_field_to_keep=data_field_to_keep, + interval_length_number=interval_length_number, + interval_length_unit=interval_length_unit, + num_intervals=num_intervals, + aggregation_strategy=aggregation_strategy, ) - # TODO: if instantiate_tensor! rdbms backed, make ds independent but build on long table _check_one_unit_per_feature(backend_handle) - # TODO ignore? go with more vanilla omop style. _check_one_unit_per_feature(ds, unit_key="unit_source_value") - unit_report = _create_feature_unit_concept_id_report(backend_handle) - var = ds["data_table_concept_id"].to_dataframe() + var = backend_handle.execute("SELECT DISTINCT data_table_concept_id FROM long_person_timestamp_feature_value").df() if enrich_var_with_feature_info or enrich_var_with_unit_info: concepts = backend_handle.sql("SELECT * FROM concept").df() @@ -398,9 +389,19 @@ def setup_variables( suffixes=("", "_unit"), ) - t = ds["interval_step"].to_dataframe() + t = pd.DataFrame({"interval_step": np.arange(num_intervals)}) - edata = EHRData(r=ds[data_field_to_keep[0]].values, obs=edata.obs, var=var, uns=edata.uns, t=t) + if instantiate_tensor: + ds = ( + (backend_handle.execute("SELECT * FROM long_person_timestamp_feature_value").df()) + .set_index(["person_id", "data_table_concept_id", "interval_step"]) + .to_xarray() + ) + + else: + ds = None + + edata = EHRData(r=ds[data_field_to_keep[0]].values if ds else None, obs=edata.obs, var=var, uns=edata.uns, t=t) edata.uns[f"unit_report_{data_tables[0]}"] = unit_report return edata @@ -420,6 +421,7 @@ def setup_interval_variables( enrich_var_with_feature_info: bool = False, enrich_var_with_unit_info: bool = False, keep_date: Literal["start", "end", "interval"] = "start", + instantiate_tensor: bool = True, ): """Setup the interval variables @@ -453,6 +455,8 @@ def setup_interval_variables( Whether to enrich the var table with feature information. If a concept_id is not found in the concept table, the feature information will be NaN. date_type Whether to keep the start or end date, or the interval span. + instantiate_tensor + Whether to instantiate the tensor into the .r field of the EHRData object. Returns ------- @@ -483,24 +487,26 @@ def setup_interval_variables( logging.warning(f"No data in {data_tables}.") return edata + _write_long_time_interval_table( + backend_handle=backend_handle, + time_defining_table=time_defining_table, + data_table=data_tables[0], + data_field_to_keep=data_field_to_keep, + interval_length_number=interval_length_number, + interval_length_unit=interval_length_unit, + num_intervals=num_intervals, + aggregation_strategy=aggregation_strategy, + keep_date=keep_date, + ) + ds = ( - _time_interval_table( - backend_handle=backend_handle, - time_defining_table=time_defining_table, - data_table=data_tables[0], - data_field_to_keep=data_field_to_keep, - interval_length_number=interval_length_number, - interval_length_unit=interval_length_unit, - num_intervals=num_intervals, - aggregation_strategy=aggregation_strategy, - keep_date=keep_date, - return_as_df=True, - ) + backend_handle.execute("SELECT * FROM long_person_timestamp_feature_value") + .df() .set_index(["person_id", "data_table_concept_id", "interval_step"]) .to_xarray() ) - var = ds["data_table_concept_id"].to_dataframe() + var = backend_handle.execute("SELECT DISTINCT data_table_concept_id FROM long_person_timestamp_feature_value").df() if enrich_var_with_feature_info or enrich_var_with_unit_info: concepts = backend_handle.sql("SELECT * FROM concept").df() @@ -509,7 +515,7 @@ def setup_interval_variables( if enrich_var_with_feature_info: var = pd.merge(var, concepts, how="left", left_index=True, right_on="concept_id") - t = ds["interval_step"].to_dataframe() + t = pd.DataFrame({"interval_step": np.arange(num_intervals)}) edata = EHRData(r=ds[data_field_to_keep[0]].values, obs=edata.obs, var=var, uns=edata.uns, t=t) From d998ada0de9dee0c4e0477b6369c9839ab877d97 Mon Sep 17 00:00:00 2001 From: eroell Date: Tue, 10 Dec 2024 23:27:53 +0100 Subject: [PATCH 05/18] activate keyword-only in setup_variables again --- src/ehrdata/io/omop/omop.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ehrdata/io/omop/omop.py b/src/ehrdata/io/omop/omop.py index 452bff6..a10f2ac 100644 --- a/src/ehrdata/io/omop/omop.py +++ b/src/ehrdata/io/omop/omop.py @@ -251,7 +251,7 @@ def setup_obs( def setup_variables( edata, - # *, + *, backend_handle: duckdb.duckdb.DuckDBPyConnection, data_tables: Sequence[Literal["measurement", "observation", "specimen"]] | Literal["measurement", "observation", "specimen"], From f2760d00ef70b7aa1b3094c115fb22861b67769c Mon Sep 17 00:00:00 2001 From: eroell Date: Sat, 14 Dec 2024 19:42:52 +0100 Subject: [PATCH 06/18] updates and ehrdataset notebook --- docs/notebooks/omop_ml.ipynb | 765 ++++++++++++++++++ .../tutorial_time_series_with_pypots.ipynb | 104 +-- src/ehrdata/tl/omop/__init__.py | 2 +- src/ehrdata/tl/omop/_dataset.py | 97 ++- tests/test_tl/test_ehrdataset.py | 34 + 5 files changed, 915 insertions(+), 87 deletions(-) create mode 100644 docs/notebooks/omop_ml.ipynb create mode 100644 tests/test_tl/test_ehrdataset.py diff --git a/docs/notebooks/omop_ml.ipynb b/docs/notebooks/omop_ml.ipynb new file mode 100644 index 0000000..e9357d5 --- /dev/null +++ b/docs/notebooks/omop_ml.ipynb @@ -0,0 +1,765 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The autoreload extension is already loaded. To reload it, use:\n", + " %reload_ext autoreload\n" + ] + } + ], + "source": [ + "%load_ext autoreload\n", + "%autoreload 2" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Deep Learning on Timeseries for the OMOP CDM with ehrdata\n", + "ehrdata offers a deep learning convenience map-style [pytorch dataset](https://pytorch.org/docs/stable/data.html#torch.utils.data.Dataset), EHRDataset.\n", + "This is the input for pytorch's Dataloader, the canonical data loading structure for deep learning models in pytorch.\n", + "\n", + "For more information on the OMOP Common Data Model (CDM), see the notebook on the [OMOP CDM](./omop_tables_tutorial.ipynb).\n", + "\n", + "For more information on advanced time series algorithms, see the notebook on [Time Series Analysis with ehrdata and PyPOTS](./tutorial_time_series_analysis_with_pypots.ipynb)." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Introduction\n", + "Disclaimer: the example usecase is for demonstration purposes only. The data preprocessing, the task definition, and the model setup are meant to be introductory. And as such lack the complexity required for proper inference. But flexible enough to build exactly this on top of it.\n", + "\n", + "## Worked example: Predict in-hospital mortality of ICU patients\n", + "We consider the task of predicting the in-hospital mortality of ICU patients, using public [MIMIC-IV demo dataset in the OMOP Common Data Model](https://physionet.org/content/mimic-iv-demo-omop/0.9/).\n", + "\n", + "Dataset:
\n", + "Kallfelz, M., Tsvetkova, A., Pollard, T., Kwong, M., Lipori, G., Huser, V., Osborn, J., Hao, S., & Williams, A. (2021). MIMIC-IV demo data in the OMOP Common Data Model (version 0.9). PhysioNet. https://doi.org/10.13026/p1f5-7x35." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Imports\n", + "We start with the required imports" + ] + }, + { + "cell_type": "code", + "execution_count": 267, + "metadata": {}, + "outputs": [], + "source": [ + "import torch\n", + "import duckdb\n", + "import numpy as np\n", + "from torch.utils.data import DataLoader\n", + "\n", + "import ehrdata as ed\n", + "import ehrapy as ep" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Setup the database\n", + "We use the plug-and-play ehrdata dataset, and duckdb as our RDMS.\n", + "#### Setup a local database connection" + ] + }, + { + "cell_type": "code", + "execution_count": 268, + "metadata": {}, + "outputs": [], + "source": [ + "con = duckdb.connect(\":memory:\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Download the data, and load it into the database\n", + "Convenience dataset available from ehrdata." + ] + }, + { + "cell_type": "code", + "execution_count": 269, + "metadata": {}, + "outputs": [], + "source": [ + "ed.dt.mimic_iv_omop(backend_handle=con)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Define the Cohort\n", + "We start off by considering only patients with a `visit_occurrence` in ICU (`visit_concept_id` in `visit_detail` for ICU: In this dataset, we choose the OMOP Concept IDs\n", + "- 4305366 for Surgical ICU\n", + "- 40481392 for Medical ICU\n", + "- 32037 for Intensive Care\n", + "- 763903 for Trauma ICU\n", + "- 4149943 for Cardiac ICU\n", + "\n", + "If a person had multiple such ICU stays, we select the first.\n", + "\n", + "There are better ways than to delete rows in `visit_occurrence` which do not satisfy our cohort definition from our database, for the toy example this is the fastest." + ] + }, + { + "cell_type": "code", + "execution_count": 270, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 270, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "con.execute(\"\"\"\n", + " WITH RankedVisits AS (\n", + " SELECT\n", + " v.*,\n", + " vd.*,\n", + " ROW_NUMBER() OVER (PARTITION BY v.person_id ORDER BY v.visit_start_date) AS rn\n", + " FROM visit_occurrence v\n", + " JOIN visit_detail vd USING (visit_occurrence_id)\n", + " WHERE vd.visit_detail_concept_id IN (4305366, 40481392, 32037, 763903, 4149943)\n", + " ),\n", + " first_icu_visit_occurrence_id AS (\n", + " SELECT visit_occurrence_id\n", + " FROM RankedVisits\n", + " WHERE rn = 1\n", + " )\n", + " DELETE FROM visit_occurrence\n", + " WHERE visit_occurrence_id NOT IN (SELECT visit_occurrence_id FROM first_icu_visit_occurrence_id)\n", + "\"\"\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Define the variables of interest and the time windows.\n", + "For more information of how we can convert the irregularly-sampled time series into a missing data problem by discretizing the time axis into non-overlapping intervals, see the Notebook on [Extracting, Representing, Validating and Vizualizing Data from an OMOP CDM Database with ehrdata, lamin, and Vitessce](./tutorial_omop_visualization.ipynb).\n", + "\n", + "Here, we decide for the following:\n", + "- We have for each person (n=100) one in-hospital stay; take the start of this hospital stay as the starting point (t=0) for each patient.\n", + "- We consider time-intervals of 1h, for 24h; that is, the first day after ICU admission. If for a patient less recorded data is available, the missing data is padded.\n", + "- We consider the data from the `measurements` table; we consider the numeric `value_as_number` values.\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We now set up the persons to be considered, together with their observation start and endtimes, retrieved from the `visit_occurrence` table." + ] + }, + { + "cell_type": "code", + "execution_count": 271, + "metadata": {}, + "outputs": [], + "source": [ + "edata = ed.io.omop.setup_obs(\n", + " backend_handle=con,\n", + " observation_table=\"person_visit_occurrence\",\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "In the next step, we retrieve all `value_as_number` entries from the `measurements` table:" + ] + }, + { + "cell_type": "code", + "execution_count": 272, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "WARNING:root:multiple units for features: [[ 1]\n", + " [ 7]\n", + " [ 31]\n", + " [ 33]\n", + " [ 39]\n", + " [ 44]\n", + " [ 49]\n", + " [ 57]\n", + " [ 60]\n", + " [ 79]\n", + " [ 81]\n", + " [ 83]\n", + " [ 93]\n", + " [110]\n", + " [160]\n", + " [175]\n", + " [186]\n", + " [187]\n", + " [189]\n", + " [190]\n", + " [195]\n", + " [204]\n", + " [207]\n", + " [221]\n", + " [269]\n", + " [273]\n", + " [275]]\n" + ] + } + ], + "source": [ + "edata = ed.io.omop.setup_variables(\n", + " edata=edata,\n", + " backend_handle=con,\n", + " data_tables=[\"measurement\"],\n", + " data_field_to_keep=[\"value_as_number\"],\n", + " interval_length_number=1,\n", + " interval_length_unit=\"h\",\n", + " num_intervals=24,\n", + " concept_ids=\"all\",\n", + " aggregation_strategy=\"last\",\n", + " instantiate_tensor=False,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "NOTE: this could/should become an ehrdata API call.\n", + "\n", + "We drop features which are not measured in at least 10 patients 1x." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 273, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# delete all rows for which data_table_concept_id is NOT NULL in at least 10 different patient_id s\n", + "con.execute(\"\"\"\n", + " WITH concept_ids_to_delete AS (\n", + "\n", + " SELECT\n", + " data_table_concept_id\n", + " FROM long_person_timestamp_feature_value\n", + " WHERE value_as_number IS NOT NULL\n", + " GROUP BY data_table_concept_id\n", + " HAVING COUNT(DISTINCT person_id) <= 10\n", + "\n", + " UNION\n", + "\n", + " SELECT\n", + " data_table_concept_id\n", + " FROM long_person_timestamp_feature_value\n", + " GROUP BY data_table_concept_id\n", + " HAVING COUNT(value_as_number) = 0\n", + " )\n", + "\n", + " DELETE FROM long_person_timestamp_feature_value\n", + " WHERE data_table_concept_id IN (\n", + " SELECT data_table_concept_id FROM concept_ids_to_delete\n", + " );\n", + "\"\"\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "NOTE: this could/should become an ehrdata API call.\n", + "\n", + "For model simpliclity, we conduct forward filling of the variables:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 274, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "con.execute(\"\"\"\n", + "WITH filled_measurements AS (\n", + " SELECT\n", + " person_id,\n", + " interval_step,\n", + " data_table_concept_id,\n", + " COALESCE(value_as_number,\n", + " LAST_VALUE(value_as_number IGNORE NULLS)\n", + " OVER (PARTITION BY person_id, data_table_concept_id\n", + " ORDER BY interval_step\n", + " ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)\n", + " ) AS filled_value\n", + " FROM long_person_timestamp_feature_value\n", + ")\n", + "UPDATE long_person_timestamp_feature_value\n", + "SET value_as_number = fm.filled_value\n", + "FROM filled_measurements as fm\n", + "WHERE long_person_timestamp_feature_value.person_id = fm.person_id\n", + "AND long_person_timestamp_feature_value.interval_step = fm.interval_step\n", + "AND long_person_timestamp_feature_value.data_table_concept_id = fm.data_table_concept_id;\n", + "\"\"\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "And for all values not captured in forward fill, we impute the missing value for person x, feature f, time step t as the mean of all other persons feature f at timestep t." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 275, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "con.execute(\"\"\"\n", + "WITH feature_means AS (\n", + " SELECT\n", + " interval_step,\n", + " data_table_concept_id,\n", + " AVG(value_as_number) AS mean_value\n", + " FROM long_person_timestamp_feature_value\n", + " WHERE value_as_number IS NOT NULL\n", + " GROUP BY interval_step, data_table_concept_id\n", + "),\n", + "filled_values AS (\n", + " SELECT\n", + " lptfv.person_id,\n", + " lptfv.interval_step,\n", + " lptfv.data_table_concept_id,\n", + " COALESCE(lptfv.value_as_number, fm.mean_value) AS filled_value\n", + " FROM long_person_timestamp_feature_value lptfv\n", + " LEFT JOIN feature_means fm\n", + " ON lptfv.interval_step = fm.interval_step\n", + " AND lptfv.data_table_concept_id = fm.data_table_concept_id\n", + ")\n", + "UPDATE long_person_timestamp_feature_value\n", + "SET value_as_number = fm.filled_value\n", + "FROM filled_values as fm\n", + "WHERE long_person_timestamp_feature_value.person_id = fm.person_id\n", + "AND long_person_timestamp_feature_value.interval_step = fm.interval_step\n", + "AND long_person_timestamp_feature_value.data_table_concept_id = fm.data_table_concept_id;\n", + "\"\"\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Deep Learning Model\n", + "#### Data Loading" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The tensor of shape n_obs x n_vars x num_intervals has been prepared in the RDBMS.\n", + "We can now create an `EHRDataset`, which is a subclass of pytorch's Dataset, and will stream the data for a deep learning model from the database." + ] + }, + { + "cell_type": "code", + "execution_count": 276, + "metadata": {}, + "outputs": [], + "source": [ + "dataset = ed.tl.omop.EHRDataset(con, edata, batch_size=5, idxs=None)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The `EHRDataset`, as subclass of pytorch's `Dataset`, can be used right away for creating a pytorch `Dataloader`." + ] + }, + { + "cell_type": "code", + "execution_count": 277, + "metadata": {}, + "outputs": [], + "source": [ + "loader = DataLoader(dataset, batch_size=4, shuffle=False)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Model definition\n", + "We create a simple model for time series based on a Recurrent Neural Network in pytorch.\n", + "More advanced models interoperable with ehrdata are showcased in [Time Series Analysis with ehrdata and PyPOTS](./tutorial_time_series_analysis_with_pypots.ipynb). However, PyPOTS does not support a pytorch Dataloader as input." + ] + }, + { + "cell_type": "code", + "execution_count": 278, + "metadata": {}, + "outputs": [], + "source": [ + "import torch.nn as nn\n", + "import torch.nn.functional as F\n", + "\n", + "\n", + "class RNN_Model(nn.Module):\n", + " \"\"\"RNN Model.\"\"\"\n", + "\n", + " def __init__(self, input_size, hidden_size, num_layers, num_classes):\n", + " super().__init__()\n", + " self.rnn = nn.RNN(input_size, hidden_size, num_layers, batch_first=True)\n", + " self.fc = nn.Linear(hidden_size, num_classes)\n", + "\n", + " def _prepare_batch(self, batch):\n", + " x, target = batch\n", + " return torch.transpose(x, 2, 1), target.flatten().to(torch.long)\n", + "\n", + " def forward(self, x):\n", + " \"\"\"Forward method.\"\"\"\n", + " # x: (batch_size, seq_len, input_size)\n", + " out, _ = self.rnn(x)\n", + " out = out[:, -1, :]\n", + "\n", + " # out: (batch_size, num_classes)\n", + " logits = self.fc(out)\n", + " return out, logits\n", + "\n", + " def training_step(self, batch):\n", + " \"\"\"Training step.\"\"\"\n", + " x, target = self._prepare_batch(batch)\n", + " out, logits = self(x)\n", + " loss = F.cross_entropy(logits, target)\n", + " return loss\n", + "\n", + " def fit(self, loader, epochs=10):\n", + " \"\"\"Fit method.\"\"\"\n", + " self.train_loss = []\n", + " optimizer = torch.optim.Adam(self.parameters(), lr=0.01)\n", + " for epoch in range(epochs):\n", + " batch_loss = []\n", + " for batch in loader:\n", + " optimizer.zero_grad()\n", + " loss = self.training_step(batch)\n", + " loss.backward()\n", + " optimizer.step()\n", + "\n", + " batch_loss.append(loss.item())\n", + "\n", + " self.train_loss.append(np.mean(np.array(batch_loss)))\n", + " print(f\"Epoch {epoch}, Loss: {np.mean(np.array(batch_loss))}\")\n", + "\n", + " def predict(self, loader, soft=True):\n", + " \"\"\"Predict method.\"\"\"\n", + " predictions = []\n", + " with torch.no_grad():\n", + " for batch in loader:\n", + " x, target = self._prepare_batch(batch)\n", + " _, classification_logits = self(x)\n", + " if soft:\n", + " predicted = torch.softmax(classification_logits, 1)\n", + " else:\n", + " predicted = torch.max(classification_logits, 1)\n", + " predictions.append(predicted)\n", + " return torch.cat(predictions)\n", + "\n", + " def represent(self, loader):\n", + " \"\"\"Represent method.\"\"\"\n", + " representations = []\n", + " with torch.no_grad():\n", + " for batch in loader:\n", + " x, target = self._prepare_batch(batch)\n", + " output, _ = self(x)\n", + " representations.append(output)\n", + " return torch.cat(representations)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Model training" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Epoch 0, Loss: 0.3569712014496326\n", + "Epoch 1, Loss: 0.3650100648403168\n", + "Epoch 2, Loss: 0.3504540580511093\n", + "Epoch 3, Loss: 0.3471323770284653\n", + "Epoch 4, Loss: 0.3453905090689659\n" + ] + } + ], + "source": [ + "model = RNN_Model(\n", + " input_size=129,\n", + " hidden_size=16,\n", + " num_layers=1,\n", + " num_classes=2,\n", + ")\n", + "model.fit(loader, epochs=5)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Model prediction\n", + "Classification could look like this" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "tensor([[0.8678, 0.1322],\n", + " [0.9055, 0.0945],\n", + " [0.8645, 0.1355],\n", + " [0.9055, 0.0945],\n", + " [0.9055, 0.0945],\n", + " [0.9055, 0.0945],\n", + " [0.9055, 0.0945],\n", + " [0.9055, 0.0945],\n", + " [0.8678, 0.1322],\n", + " [0.9055, 0.0945],\n", + " [0.9055, 0.0945],\n", + " [0.8678, 0.1322],\n", + " [0.8678, 0.1322],\n", + " [0.9055, 0.0945],\n", + " [0.9055, 0.0945],\n", + " [0.9055, 0.0945],\n", + " [0.9056, 0.0944],\n", + " [0.8678, 0.1322],\n", + " [0.9055, 0.0945],\n", + " [0.9055, 0.0945],\n", + " [0.8678, 0.1322],\n", + " [0.9055, 0.0945],\n", + " [0.8678, 0.1322],\n", + " [0.9055, 0.0945],\n", + " [0.9055, 0.0945],\n", + " [0.8678, 0.1322],\n", + " [0.9055, 0.0945],\n", + " [0.9055, 0.0945],\n", + " [0.9055, 0.0945],\n", + " [0.8678, 0.1322],\n", + " [0.9055, 0.0945],\n", + " [0.9055, 0.0945],\n", + " [0.9055, 0.0945],\n", + " [0.9055, 0.0945],\n", + " [0.9055, 0.0945],\n", + " [0.8678, 0.1322],\n", + " [0.9055, 0.0945],\n", + " [0.8678, 0.1322],\n", + " [0.8114, 0.1886],\n", + " [0.8678, 0.1322],\n", + " [0.9055, 0.0945],\n", + " [0.9055, 0.0945],\n", + " [0.9055, 0.0945],\n", + " [0.9055, 0.0945],\n", + " [0.9056, 0.0944],\n", + " [0.8678, 0.1322],\n", + " [0.9055, 0.0945],\n", + " [0.8678, 0.1322],\n", + " [0.9055, 0.0945],\n", + " [0.9055, 0.0945],\n", + " [0.9055, 0.0945],\n", + " [0.8678, 0.1322],\n", + " [0.9055, 0.0945],\n", + " [0.8678, 0.1322],\n", + " [0.9055, 0.0945],\n", + " [0.8678, 0.1322],\n", + " [0.9055, 0.0945],\n", + " [0.9055, 0.0945],\n", + " [0.8678, 0.1322],\n", + " [0.8678, 0.1322],\n", + " [0.9055, 0.0945],\n", + " [0.8678, 0.1322],\n", + " [0.9055, 0.0945],\n", + " [0.9055, 0.0945],\n", + " [0.9055, 0.0945],\n", + " [0.9055, 0.0945],\n", + " [0.8678, 0.1322],\n", + " [0.9055, 0.0945],\n", + " [0.9055, 0.0945],\n", + " [0.9055, 0.0945],\n", + " [0.9055, 0.0945],\n", + " [0.9055, 0.0945],\n", + " [0.8678, 0.1322],\n", + " [0.9055, 0.0945],\n", + " [0.9055, 0.0945],\n", + " [0.9055, 0.0945],\n", + " [0.8542, 0.1458],\n", + " [0.9055, 0.0945],\n", + " [0.9055, 0.0945],\n", + " [0.8678, 0.1322],\n", + " [0.9055, 0.0945],\n", + " [0.8678, 0.1322],\n", + " [0.9055, 0.0945],\n", + " [0.9055, 0.0945],\n", + " [0.7546, 0.2454],\n", + " [0.9055, 0.0945],\n", + " [0.8678, 0.1322],\n", + " [0.9055, 0.0945],\n", + " [0.8138, 0.1862],\n", + " [0.9055, 0.0945],\n", + " [0.9055, 0.0945],\n", + " [0.9055, 0.0945],\n", + " [0.9055, 0.0945],\n", + " [0.9055, 0.0945],\n", + " [0.9055, 0.0945],\n", + " [0.9055, 0.0945],\n", + " [0.9055, 0.0945],\n", + " [0.9055, 0.0945],\n", + " [0.9055, 0.0945],\n", + " [0.9055, 0.0945]])" + ] + }, + "execution_count": 280, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "model.predict(loader)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Model representation\n", + "Futher, could illustrate patient representation:" + ] + }, + { + "cell_type": "code", + "execution_count": 281, + "metadata": {}, + "outputs": [], + "source": [ + "edata.obsm[\"last_step_representation\"] = np.array(model.represent(loader))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "ep.pp.neighbors(edata, use_rep=\"last_step_representation\")\n", + "ep.tl.umap(edata)\n", + "ep.pl.umap(\n", + " edata, color=\"discharge_to_source_value\", title=\"UMAP of RNN representation after 24h colored by discharge note\"\n", + ")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "ehrapy_venv_oct", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.9" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/docs/notebooks/tutorial_time_series_with_pypots.ipynb b/docs/notebooks/tutorial_time_series_with_pypots.ipynb index 3fbe3d2..4e4c9d6 100644 --- a/docs/notebooks/tutorial_time_series_with_pypots.ipynb +++ b/docs/notebooks/tutorial_time_series_with_pypots.ipynb @@ -32,7 +32,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 1, "metadata": {}, "outputs": [], "source": [ @@ -41,9 +41,35 @@ }, { "cell_type": "code", - "execution_count": 58, + "execution_count": 2, "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/Users/eljas.roellin/Documents/ehrapy_workspace/ehrapy_venv_oct/lib/python3.11/site-packages/pypots/nn/modules/reformer/local_attention.py:31: FutureWarning: `torch.cuda.amp.autocast(args...)` is deprecated. Please use `torch.amp.autocast('cuda', args...)` instead.\n", + " @autocast(enabled=False)\n", + "/Users/eljas.roellin/Documents/ehrapy_workspace/ehrapy_venv_oct/lib/python3.11/site-packages/pypots/nn/modules/reformer/local_attention.py:98: FutureWarning: `torch.cuda.amp.autocast(args...)` is deprecated. Please use `torch.amp.autocast('cuda', args...)` instead.\n", + " @autocast(enabled=False)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\u001b[34m\n", + "████████╗██╗███╗ ███╗███████╗ ███████╗███████╗██████╗ ██╗███████╗███████╗ █████╗ ██╗\n", + "╚══██╔══╝██║████╗ ████║██╔════╝ ██╔════╝██╔════╝██╔══██╗██║██╔════╝██╔════╝ ██╔══██╗██║\n", + " ██║ ██║██╔████╔██║█████╗█████╗███████╗█████╗ ██████╔╝██║█████╗ ███████╗ ███████║██║\n", + " ██║ ██║██║╚██╔╝██║██╔══╝╚════╝╚════██║██╔══╝ ██╔══██╗██║██╔══╝ ╚════██║ ██╔══██║██║\n", + " ██║ ██║██║ ╚═╝ ██║███████╗ ███████║███████╗██║ ██║██║███████╗███████║██╗██║ ██║██║\n", + " ╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝ ╚══════╝╚══════╝╚═╝ ╚═╝╚═╝╚══════╝╚══════╝╚═╝╚═╝ ╚═╝╚═╝\n", + "ai4ts v0.0.3 - building AI for unified time-series analysis, https://time-series.ai \u001b[0m\n", + "\n" + ] + } + ], "source": [ "import duckdb\n", "import ehrdata as ed\n", @@ -60,72 +86,24 @@ }, { "cell_type": "code", - "execution_count": 41, + "execution_count": 3, "metadata": {}, "outputs": [ { - "name": "stderr", - "output_type": "stream", - "text": [ - "INFO - Downloading Synthea27Nj_5.4.zip from https://github.com/OHDSI/EunomiaDatasets/raw/main/datasets/Synthea27Nj/Synthea27Nj_5.4.zip to /var/folders/yy/60ln_681745_fjjwvgwm_nyc0000gn/T/tmpfndmdvwt/Synthea27Nj_5.4.zip\n" - ] - }, - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "254776f379994eeab1835ffe42fe89a1", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "Output()" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "
\n"
-      ],
-      "text/plain": []
-     },
-     "metadata": {},
-     "output_type": "display_data"
-    },
-    {
-     "name": "stderr",
-     "output_type": "stream",
-     "text": [
-      "INFO - Extracted archive Synthea27Nj_5.4.zip from /var/folders/yy/60ln_681745_fjjwvgwm_nyc0000gn/T/tmpfndmdvwt/Synthea27Nj_5.4.zip to ehrapy_data/Synthea27Nj_5.4/Synthea27Nj_5.4\n",
-      "INFO - missing tables: []\n",
-      "INFO - unused files: ['EPISODE.csv', '__MACOSX', 'EPISODE_EVENT.csv']\n"
-     ]
-    },
-    {
-     "name": "stdout",
-     "output_type": "stream",
-     "text": [
-      "multiple units for features: []\n"
+     "ename": "",
+     "evalue": "",
+     "output_type": "error",
+     "traceback": [
+      "\u001b[1;31mCannot execute code, session has been disposed. Please try restarting the Kernel."
      ]
     },
     {
-     "name": "stderr",
-     "output_type": "stream",
-     "text": [
-      "/Users/eljas.roellin/Documents/ehrapy_workspace/ehrapy_venv_oct/lib/python3.11/site-packages/anndata/_core/aligned_df.py:68: ImplicitModificationWarning: Transforming to str index.\n",
-      "  warnings.warn(\"Transforming to str index.\", ImplicitModificationWarning)\n",
-      "/Users/eljas.roellin/Documents/ehrapy_workspace/ehrapy_venv_oct/lib/python3.11/site-packages/pandas/core/generic.py:3331: UserWarning: Converting non-nanosecond precision datetime values to nanosecond precision. This behavior can eventually be relaxed in xarray, as it is an artifact from pandas which is now beginning to support non-nanosecond precision values. This warning is caused by passing non-nanosecond np.datetime64 or np.timedelta64 values to the DataArray or Variable constructor; it can be silenced by converting the values to nanosecond precision ahead of time.\n",
-      "  return xarray.Dataset.from_dataframe(self)\n",
-      "/Users/eljas.roellin/Documents/ehrapy_workspace/ehrapy_venv_oct/lib/python3.11/site-packages/pandas/core/generic.py:3331: UserWarning: Converting non-nanosecond precision datetime values to nanosecond precision. This behavior can eventually be relaxed in xarray, as it is an artifact from pandas which is now beginning to support non-nanosecond precision values. This warning is caused by passing non-nanosecond np.datetime64 or np.timedelta64 values to the DataArray or Variable constructor; it can be silenced by converting the values to nanosecond precision ahead of time.\n",
-      "  return xarray.Dataset.from_dataframe(self)\n",
-      "/Users/eljas.roellin/Documents/ehrapy_workspace/ehrapy_venv_oct/lib/python3.11/site-packages/pandas/core/generic.py:3331: UserWarning: Converting non-nanosecond precision datetime values to nanosecond precision. This behavior can eventually be relaxed in xarray, as it is an artifact from pandas which is now beginning to support non-nanosecond precision values. This warning is caused by passing non-nanosecond np.datetime64 or np.timedelta64 values to the DataArray or Variable constructor; it can be silenced by converting the values to nanosecond precision ahead of time.\n",
-      "  return xarray.Dataset.from_dataframe(self)\n",
-      "/Users/eljas.roellin/Documents/ehrapy_workspace/ehrapy_venv_oct/lib/python3.11/site-packages/pandas/core/generic.py:3331: UserWarning: Converting non-nanosecond precision datetime values to nanosecond precision. This behavior can eventually be relaxed in xarray, as it is an artifact from pandas which is now beginning to support non-nanosecond precision values. This warning is caused by passing non-nanosecond np.datetime64 or np.timedelta64 values to the DataArray or Variable constructor; it can be silenced by converting the values to nanosecond precision ahead of time.\n",
-      "  return xarray.Dataset.from_dataframe(self)\n",
-      "/Users/eljas.roellin/Documents/ehrapy_workspace/ehrapy_venv_oct/lib/python3.11/site-packages/anndata/_core/aligned_df.py:68: ImplicitModificationWarning: Transforming to str index.\n",
-      "  warnings.warn(\"Transforming to str index.\", ImplicitModificationWarning)\n"
+     "ename": "",
+     "evalue": "",
+     "output_type": "error",
+     "traceback": [
+      "\u001b[1;31mCannot execute code, session has been disposed. Please try restarting the Kernel. \n",
+      "\u001b[1;31mView Jupyter log for further details."
      ]
     }
    ],
diff --git a/src/ehrdata/tl/omop/__init__.py b/src/ehrdata/tl/omop/__init__.py
index 72518bc..4ba6ce9 100644
--- a/src/ehrdata/tl/omop/__init__.py
+++ b/src/ehrdata/tl/omop/__init__.py
@@ -1 +1 @@
-from ._dataset import EHRDataSet
+from ._dataset import EHRDataset
diff --git a/src/ehrdata/tl/omop/_dataset.py b/src/ehrdata/tl/omop/_dataset.py
index 0a41bfc..d3d4708 100644
--- a/src/ehrdata/tl/omop/_dataset.py
+++ b/src/ehrdata/tl/omop/_dataset.py
@@ -1,27 +1,63 @@
 from collections.abc import Sequence
+from typing import Literal
 
 import torch
 from duckdb.duckdb import DuckDBPyConnection
 from torch.utils.data import Dataset
 
+from ehrdata.io.omop._queries import DATA_TABLE_DATE_KEYS
 
-class EHRDataSet(Dataset):
+
+class EHRDataset(Dataset):
     def __init__(
         self,
         con: DuckDBPyConnection,
-        n_variables: int,
-        n_timesteps: int,
+        edata,
         batch_size: int = 10,
+        target: Literal["mortality"] = "mortality",
+        datetime: bool = True,
         idxs: Sequence[int] | None = None,
-    ):
+    ) -> torch.utils.data.Dataset:
+        """Return a torch.utils.data.Dataset object for EHR data.
+
+        This function builds a torch.utils.data.Dataset object for EHR data. The EHR data is assumed to be in the OMOP CDM format.
+        It is a Dataset structure for the tensor in ehrdata.r, in a suitable format for pytorch.utils.data.DataLoader.
+        This allows to stream the data in batches from the RDBMS, not requiring to load the entire dataset in memory.
+
+        Parameters
+        ----------
+        con
+            The connection to the database.
+        edata
+            The EHRData object.
+        batch_size
+            The batch size.
+        target
+            The target variable to be used.
+        datetime
+            If True, use datetime, if False, use date.
+        idxs
+            The indices of the patients to be used, can be used to include only a subset of the data, for e.g. train-test splits.
+            The observation table to be used.
+
+        Returns
+        -------
+        EHRDataset
+            A torch.utils.data.Dataset object of the .r tensor in ehrdata.
+        """
         super().__init__()
         self.con = con
-        self.batch_size = batch_size
+        self.edata = edata
+        self.target = target
+        self.datetime = datetime
         self.idxs = idxs
 
-        # TODO: get from database or EHRData?
-        self.n_timesteps = n_timesteps
-        self.n_variables = n_variables
+        self.n_timesteps = con.execute(
+            "SELECT COUNT(DISTINCT interval_step) FROM long_person_timestamp_feature_value"
+        ).fetchone()[0]
+        self.n_variables = con.execute(
+            "SELECT COUNT(DISTINCT data_table_concept_id) FROM long_person_timestamp_feature_value"
+        ).fetchone()[0]
 
     def __len__(self):
         if self.idxs:
@@ -33,37 +69,52 @@ def __len__(self):
             FROM long_person_timestamp_feature_value
             {where_clause}
         """
-        return self.con.execute(query).fetchone()[0]  # .item()
-
-    def __getitem__(self, person_id):
-        # if isinstance(person_ids, int):
-        #     person_ids = [person_ids]  # Make it a list for consistent handling
-        # elif isinstance(person_ids, slice):
-        #     person_ids = range(person_ids.start or 0, person_ids.stop, person_ids.step or 1)
+        return self.con.execute(query).fetchone()[0]
 
-        where_clause = f"WHERE person_index = {person_id}"
+    def __getitem__(self, person_index):
+        person_id_query = (
+            f"SELECT DISTINCT person_id FROM long_person_timestamp_feature_value WHERE person_index = {person_index}"
+        )
+        person_id = self.con.execute(person_id_query).fetchone()[0]
+        where_clause = f"WHERE person_index = {person_index}"
 
         if self.idxs:
             where_clause += f" AND person_index IN ({','.join(str(_) for _ in self.idxs)})"
-        # else:
-        #     where_clause = ""
 
         query = f"""
             SELECT person_index, data_table_concept_id, interval_step, COALESCE(CAST(value_as_number AS DOUBLE), 'NaN') AS value_as_number
             FROM long_person_timestamp_feature_value
             {where_clause}
         """
-        # AND data_table_concept_id = {feature_id}
-        # AND interval_step = {timestep}
-        # data is fetched in long format
+
         long_format_data = torch.tensor(self.con.execute(query).fetchall(), dtype=torch.float32)
 
         # convert long format to 3D tensor
-        # sample_ids, sample_idx = torch.unique(long_format_data[:, 0], return_inverse=True)
         feature_ids, feature_idx = torch.unique(long_format_data[:, 1], return_inverse=True)
         step_ids, step_idx = torch.unique(long_format_data[:, 2], return_inverse=True)
 
         result = torch.zeros(len(feature_ids), len(step_ids))
         values = long_format_data[:, 3]
         result.index_put_((feature_idx, step_idx), values)
-        return result
+
+        if self.target != "mortality":
+            raise NotImplementedError(f"Target {self.target} is not implemented")
+
+        # If person has an entry in the death table that is within visit_start_datetime and visit_end_datetime of the visit_occurrence table, report 1, else 0:
+        # Left join ensures that for every patient, 0 or 1 is obtained
+        omop_io_observation_table = self.edata.uns["omop_io_observation_table"]
+        time_postfix = "time" if self.datetime else ""
+        target_query = f"""
+        SELECT
+            CASE
+                WHEN death_datetime BETWEEN {DATA_TABLE_DATE_KEYS["start"][omop_io_observation_table]}{time_postfix} AND {DATA_TABLE_DATE_KEYS["end"][omop_io_observation_table]}{time_postfix} THEN 1
+                ELSE 0
+            END AS mortality
+        FROM {self.edata.uns["omop_io_observation_table"]}
+        LEFT JOIN death USING (person_id)
+        WHERE person_id = {person_id} AND {omop_io_observation_table}_id = {self.edata.obs[self.edata.obs["person_id"] == person_id][f"{omop_io_observation_table}_id"].item()}
+        """
+
+        targets = torch.tensor(self.con.execute(target_query).fetchall(), dtype=torch.float32)
+
+        return result, targets
diff --git a/tests/test_tl/test_ehrdataset.py b/tests/test_tl/test_ehrdataset.py
new file mode 100644
index 0000000..3137b6c
--- /dev/null
+++ b/tests/test_tl/test_ehrdataset.py
@@ -0,0 +1,34 @@
+import torch
+
+import ehrdata as ed
+
+
+def test_ehrdataset_vanilla(omop_connection_vanilla):
+    num_intervals = 3
+    batch_size = 2
+    con = omop_connection_vanilla
+
+    edata = ed.io.omop.setup_obs(con, observation_table="person_observation_period", death_table=True)
+    edata = ed.io.omop.setup_variables(
+        edata,
+        backend_handle=con,
+        data_tables="measurement",
+        data_field_to_keep="value_as_number",
+        interval_length_number=1,
+        interval_length_unit="day",
+        num_intervals=num_intervals,
+        enrich_var_with_feature_info=False,
+        enrich_var_with_unit_info=False,
+        instantiate_tensor=False,
+    )
+
+    ehr_dataset = ed.tl.omop.EHRDataset(con, edata, batch_size=batch_size, datetime=False, idxs=None)
+    assert isinstance(ehr_dataset, torch.utils.data.Dataset)
+    single_item = next(iter(ehr_dataset))
+    assert single_item[0].shape == (2, num_intervals)
+    assert len(single_item[1]) == 1
+
+    loader = torch.utils.data.DataLoader(ehr_dataset, batch_size=batch_size)
+    batch = next(iter(loader))
+    assert batch[0].shape == (batch_size, 2, num_intervals)
+    assert len(batch[1]) == batch_size

From 5f657257a3b3145d378562de88a6001a1c24fb52 Mon Sep 17 00:00:00 2001
From: "pre-commit-ci[bot]"
 <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Date: Sat, 14 Dec 2024 18:43:06 +0000
Subject: [PATCH 07/18] [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci
---
 CHANGELOG.md         |  2 +-
 docs/contributing.md | 18 +++++++++---------
 2 files changed, 10 insertions(+), 10 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index e7b7808..c185628 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,4 +12,4 @@ and this project adheres to [Semantic Versioning][].
 
 ### Added
 
--   Basic tool, preprocessing and plotting functions
+- Basic tool, preprocessing and plotting functions
diff --git a/docs/contributing.md b/docs/contributing.md
index 8a9b28d..1f20499 100644
--- a/docs/contributing.md
+++ b/docs/contributing.md
@@ -155,11 +155,11 @@ This will automatically create a git tag and trigger a Github workflow that crea
 Please write documentation for new or changed features and use-cases.
 This project uses [sphinx][] with the following features:
 
--   The [myst][] extension allows to write documentation in markdown/Markedly Structured Text
--   [Numpy-style docstrings][numpydoc] (through the [napoloen][numpydoc-napoleon] extension).
--   Jupyter notebooks as tutorials through [myst-nb][] (See [Tutorials with myst-nb](#tutorials-with-myst-nb-and-jupyter-notebooks))
--   [sphinx-autodoc-typehints][], to automatically reference annotated input and output types
--   Citations (like {cite:p}`Virshup_2023`) can be included with [sphinxcontrib-bibtex](https://sphinxcontrib-bibtex.readthedocs.io/)
+- The [myst][] extension allows to write documentation in markdown/Markedly Structured Text
+- [Numpy-style docstrings][numpydoc] (through the [napoloen][numpydoc-napoleon] extension).
+- Jupyter notebooks as tutorials through [myst-nb][] (See [Tutorials with myst-nb](#tutorials-with-myst-nb-and-jupyter-notebooks))
+- [sphinx-autodoc-typehints][], to automatically reference annotated input and output types
+- Citations (like {cite:p}`Virshup_2023`) can be included with [sphinxcontrib-bibtex](https://sphinxcontrib-bibtex.readthedocs.io/)
 
 See scanpy’s {doc}`scanpy:dev/documentation` for more information on how to write your own.
 
@@ -183,10 +183,10 @@ please check out [this feature request][issue-render-notebooks] in the `cookiecu
 
 #### Hints
 
--   If you refer to objects from other packages, please add an entry to `intersphinx_mapping` in `docs/conf.py`.
-    Only if you do so can sphinx automatically create a link to the external documentation.
--   If building the documentation fails because of a missing link that is outside your control,
-    you can add an entry to the `nitpick_ignore` list in `docs/conf.py`
+- If you refer to objects from other packages, please add an entry to `intersphinx_mapping` in `docs/conf.py`.
+  Only if you do so can sphinx automatically create a link to the external documentation.
+- If building the documentation fails because of a missing link that is outside your control,
+  you can add an entry to the `nitpick_ignore` list in `docs/conf.py`
 
 (docs-building)=
 

From ae6fba4af71b60bcd29c80cac55d96cdb174f870 Mon Sep 17 00:00:00 2001
From: eroell 
Date: Sat, 14 Dec 2024 20:19:21 +0100
Subject: [PATCH 08/18] torch optional dependency

---
 pyproject.toml                               |  6 +++++-
 src/ehrdata/core/_optional_modules_import.py |  9 +++++++++
 src/ehrdata/tl/omop/_dataset.py              | 12 ++++++++----
 3 files changed, 22 insertions(+), 5 deletions(-)
 create mode 100644 src/ehrdata/core/_optional_modules_import.py

diff --git a/pyproject.toml b/pyproject.toml
index b9f4a39..e2b2bf6 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -66,7 +66,7 @@ optional-dependencies.lamin = [
 ]
 optional-dependencies.test = [
   "coverage",
-  "ehrdata[vitessce,lamin]",
+  "ehrdata[torch,vitessce,lamin]",
   "pytest",
 ]
 optional-dependencies.vitessce = [
@@ -78,6 +78,10 @@ urls.Documentation = "https://ehrdata.readthedocs.io/"
 urls.Homepage = "https://github.com/theislab/ehrdata"
 urls.Source = "https://github.com/theislab/ehrdata"
 
+optional-depencencies.torch = [
+  "torch",
+]
+
 [tool.hatch.envs.default]
 installer = "uv"
 features = [ "dev" ]
diff --git a/src/ehrdata/core/_optional_modules_import.py b/src/ehrdata/core/_optional_modules_import.py
new file mode 100644
index 0000000..aee28a4
--- /dev/null
+++ b/src/ehrdata/core/_optional_modules_import.py
@@ -0,0 +1,9 @@
+def lazy_import_torch():
+    try:
+        import torch
+
+        return torch
+    except ImportError:
+        raise ImportError(
+            "The optional module 'torch' is not installed. Please install it using 'pip install ehrdata[torch]'."
+        ) from None
diff --git a/src/ehrdata/tl/omop/_dataset.py b/src/ehrdata/tl/omop/_dataset.py
index d3d4708..723ab5b 100644
--- a/src/ehrdata/tl/omop/_dataset.py
+++ b/src/ehrdata/tl/omop/_dataset.py
@@ -1,14 +1,18 @@
 from collections.abc import Sequence
-from typing import Literal
+from typing import TYPE_CHECKING, Literal
 
-import torch
 from duckdb.duckdb import DuckDBPyConnection
-from torch.utils.data import Dataset
 
+from ehrdata.core._optional_modules_import import lazy_import_torch
 from ehrdata.io.omop._queries import DATA_TABLE_DATE_KEYS
 
+torch = lazy_import_torch()
 
-class EHRDataset(Dataset):
+if TYPE_CHECKING:
+    import torch
+
+
+class EHRDataset(torch.utils.data.Dataset):
     def __init__(
         self,
         con: DuckDBPyConnection,

From b79bf71b7685ada064476c28fae04253c7d3c095 Mon Sep 17 00:00:00 2001
From: eroell 
Date: Sat, 14 Dec 2024 20:24:10 +0100
Subject: [PATCH 09/18] fix typo

---
 pyproject.toml | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/pyproject.toml b/pyproject.toml
index e2b2bf6..1ed3648 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -69,6 +69,10 @@ optional-dependencies.test = [
   "ehrdata[torch,vitessce,lamin]",
   "pytest",
 ]
+optional-dependencies.torch = [
+  "torch",
+]
+
 optional-dependencies.vitessce = [
   "vitessce[all]>=3.4", # the actual dependency
   "zarr<3",             # vitessce does not support zarr>=3
@@ -78,10 +82,6 @@ urls.Documentation = "https://ehrdata.readthedocs.io/"
 urls.Homepage = "https://github.com/theislab/ehrdata"
 urls.Source = "https://github.com/theislab/ehrdata"
 
-optional-depencencies.torch = [
-  "torch",
-]
-
 [tool.hatch.envs.default]
 installer = "uv"
 features = [ "dev" ]

From c4bbb7349d9340a6962d0acd24942498b43dfe4d Mon Sep 17 00:00:00 2001
From: eroell 
Date: Sat, 14 Dec 2024 21:36:15 +0100
Subject: [PATCH 10/18] add torch do doc dependencies

---
 pyproject.toml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/pyproject.toml b/pyproject.toml
index 1ed3648..68fba82 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -39,7 +39,7 @@ optional-dependencies.dev = [
 ]
 optional-dependencies.doc = [
   "docutils>=0.8,!=0.18.*,!=0.19.*",
-  "ehrdata[lamin,vitessce]",
+  "ehrdata[torch,lamin,vitessce]",
   "ipykernel",
   "ipython",
   "myst-nb>=1.1",

From ddafaa37dfec45368d9c9a44c13198ebd2434dea Mon Sep 17 00:00:00 2001
From: eroell 
Date: Sat, 14 Dec 2024 21:43:34 +0100
Subject: [PATCH 11/18] fix doc warning w/ broken link

---
 docs/notebooks/omop_ml.ipynb | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/docs/notebooks/omop_ml.ipynb b/docs/notebooks/omop_ml.ipynb
index e9357d5..91368de 100644
--- a/docs/notebooks/omop_ml.ipynb
+++ b/docs/notebooks/omop_ml.ipynb
@@ -29,7 +29,7 @@
     "\n",
     "For more information on the OMOP Common Data Model (CDM), see the notebook on the [OMOP CDM](./omop_tables_tutorial.ipynb).\n",
     "\n",
-    "For more information on advanced time series algorithms, see the notebook on [Time Series Analysis with ehrdata and PyPOTS](./tutorial_time_series_analysis_with_pypots.ipynb)."
+    "For more information on advanced time series algorithms, see the notebook on [Time Series Analysis with ehrdata and PyPOTS](./tutorial_time_series_with_pypots.ipynb)."
    ]
   },
   {

From 82184b7ca5b63618a54391040ff7cee074910c1b Mon Sep 17 00:00:00 2001
From: eroell 
Date: Sat, 14 Dec 2024 21:49:32 +0100
Subject: [PATCH 12/18] fix doc warning w/ 2nd broken link

---
 docs/notebooks/omop_ml.ipynb | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/docs/notebooks/omop_ml.ipynb b/docs/notebooks/omop_ml.ipynb
index 91368de..25c6b63 100644
--- a/docs/notebooks/omop_ml.ipynb
+++ b/docs/notebooks/omop_ml.ipynb
@@ -454,7 +454,7 @@
    "source": [
     "#### Model definition\n",
     "We create a simple model for time series based on a Recurrent Neural Network in pytorch.\n",
-    "More advanced models interoperable with ehrdata are showcased in [Time Series Analysis with ehrdata and PyPOTS](./tutorial_time_series_analysis_with_pypots.ipynb). However, PyPOTS does not support a pytorch Dataloader as input."
+    "More advanced models interoperable with ehrdata are showcased in [Time Series Analysis with ehrdata and PyPOTS](./tutorial_time_series_with_pypots.ipynb). However, PyPOTS does not support a pytorch Dataloader as input."
    ]
   },
   {

From 947afdbbdd6068f38b9df397239f5578add1e667 Mon Sep 17 00:00:00 2001
From: eroell 
Date: Sat, 14 Dec 2024 21:55:49 +0100
Subject: [PATCH 13/18] add omop_ml to index.md

---
 docs/index.md | 1 +
 1 file changed, 1 insertion(+)

diff --git a/docs/index.md b/docs/index.md
index 7065d92..324eab5 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -17,4 +17,5 @@ notebooks/cohort_definition
 notebooks/study_design_example_omop_cdm
 notebooks/indwelling_arterial_catheters
 notebooks/tutorial_time_series_with_pypots
+notebooks/omop_ml
 ```

From e35c194879997c83902ccbe634af2898738774d1 Mon Sep 17 00:00:00 2001
From: eroell 
Date: Sat, 14 Dec 2024 22:04:31 +0100
Subject: [PATCH 14/18] fix display of links

---
 src/ehrdata/dt/datasets.py | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/src/ehrdata/dt/datasets.py b/src/ehrdata/dt/datasets.py
index 94a6e2e..4fa4009 100644
--- a/src/ehrdata/dt/datasets.py
+++ b/src/ehrdata/dt/datasets.py
@@ -85,7 +85,7 @@ def mimic_iv_omop(backend_handle: DuckDBPyConnection, data_path: Path | None = N
 def gibleed_omop(backend_handle: DuckDBPyConnection, data_path: Path | None = None) -> None:
     """Loads the GiBleed dataset in the OMOP Common Data model.
 
-    This function loads the GIBleed dataset from the `EunomiaDatasets repository _`.
+    This function loads the GIBleed dataset from the `EunomiaDatasets repository `_.
     More details: https://github.com/OHDSI/EunomiaDatasets/tree/main/datasets/GiBleed.
 
     Parameters
@@ -124,7 +124,7 @@ def gibleed_omop(backend_handle: DuckDBPyConnection, data_path: Path | None = No
 def synthea27nj_omop(backend_handle: DuckDBPyConnection, data_path: Path | None = None) -> None:
     """Loads the Synthea27Nj dataset in the OMOP Common Data model.
 
-    This function loads the Synthea27Nj dataset from the `EunomiaDatasets repository _`.
+    This function loads the Synthea27Nj dataset from the `EunomiaDatasets repository `_.
     More details: https://github.com/OHDSI/EunomiaDatasets/tree/main/datasets/Synthea27Nj.
 
     Parameters
@@ -186,7 +186,7 @@ def physionet2012(
         "142998",
     ],
 ) -> EHRData:
-    """Loads the dataset of the `PhysioNet challenge 2012 (v1.0.0) _`.
+    """Loads the dataset of the `PhysioNet challenge 2012 (v1.0.0) `_.
 
     If interval_length_number is 1, interval_length_unit is "h" (hour), and num_intervals is 48, this is equivalent to the SAITS preprocessing (insert paper/link/citation).
     Truncated if a sample has more num_intervals steps; Padded if a sample has less than num_intervals steps.

From d2012b7a3e59f042d56d5d4b19ea7adb426f3831 Mon Sep 17 00:00:00 2001
From: eroell 
Date: Sat, 14 Dec 2024 22:04:58 +0100
Subject: [PATCH 15/18] add new things to api doc

---
 docs/api.md | 13 +++++++++++++
 1 file changed, 13 insertions(+)

diff --git a/docs/api.md b/docs/api.md
index c2ab7a7..5acf144 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -37,9 +37,22 @@
     dt.mimic_iv_omop
     dt.gibleed_omop
     dt.synthea27nj_omop
+    dt.physionet2012
     dt.mimic_ii
 ```
 
+## Tools
+
+```{eval-rst}
+.. module:: ehrdata.tl
+.. currentmodule:: ehrdata
+
+.. autosummary::
+    :toctree: generated
+
+    tl.omop.EHRDataset
+```
+
 ## Plotting
 
 ```{eval-rst}

From eb35673f81952170deefbd5255795e6893565587 Mon Sep 17 00:00:00 2001
From: eroell 
Date: Sat, 14 Dec 2024 22:12:58 +0100
Subject: [PATCH 16/18] fill placeholders w/ links

---
 src/ehrdata/dt/datasets.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/ehrdata/dt/datasets.py b/src/ehrdata/dt/datasets.py
index 4fa4009..dda046e 100644
--- a/src/ehrdata/dt/datasets.py
+++ b/src/ehrdata/dt/datasets.py
@@ -188,11 +188,11 @@ def physionet2012(
 ) -> EHRData:
     """Loads the dataset of the `PhysioNet challenge 2012 (v1.0.0) `_.
 
-    If interval_length_number is 1, interval_length_unit is "h" (hour), and num_intervals is 48, this is equivalent to the SAITS preprocessing (insert paper/link/citation).
+    If interval_length_number is 1, interval_length_unit is "h" (hour), and num_intervals is 48, this is equivalent to the `SAITS `_ preprocessing.
     Truncated if a sample has more num_intervals steps; Padded if a sample has less than num_intervals steps.
     Further, by default the following 12 samples are dropped since they have no time series information at all: 147514, 142731, 145611, 140501, 155655, 143656, 156254, 150309,
     140936, 141264, 150649, 142998.
-    Taken the defaults of interval_length_number, interval_length_unit, num_intervals, and drop_samples, the tensor stored in .r of edata is the same as when doing the PyPOTS  preprocessing.
+    Taken the defaults of interval_length_number, interval_length_unit, num_intervals, and drop_samples, the tensor stored in .r of edata is the same as when doing the `PyPOTS `_ preprocessing.
     A simple deviation is that the tensor in ehrdata is of shape n_obs x n_vars x n_intervals (with defaults, 3000x37x48) while the tensor in PyPOTS is of shape n_obs x n_intervals x n_vars (3000x48x37).
     The tensor stored in .r is hence also fully compatible with the PyPOTS package, as the .r tensor of EHRData objects generally is.
 

From 3f5a1a096f110c1ed4119c8180dbb4f3beff73f6 Mon Sep 17 00:00:00 2001
From: eroell 
Date: Sat, 14 Dec 2024 22:13:36 +0100
Subject: [PATCH 17/18] fix 1 more link

---
 src/ehrdata/dt/datasets.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/ehrdata/dt/datasets.py b/src/ehrdata/dt/datasets.py
index dda046e..2ddba6c 100644
--- a/src/ehrdata/dt/datasets.py
+++ b/src/ehrdata/dt/datasets.py
@@ -44,7 +44,7 @@ def _setup_eunomia_datasets(
 def mimic_iv_omop(backend_handle: DuckDBPyConnection, data_path: Path | None = None) -> None:
     """Loads the MIMIC-IV demo data in the OMOP Common Data model.
 
-    This function loads the MIMIC-IV demo dataset from its `physionet repository _` .
+    This function loads the MIMIC-IV demo dataset from its `physionet repository `_.
     See also this link for more details.
 
     DOI https://doi.org/10.13026/2d25-8g07.

From cac3fc16459a83d4acdf30a6b4d99e66e0739136 Mon Sep 17 00:00:00 2001
From: eroell 
Date: Sat, 14 Dec 2024 22:14:37 +0100
Subject: [PATCH 18/18] remove mimicii for now from api

---
 docs/api.md | 1 -
 1 file changed, 1 deletion(-)

diff --git a/docs/api.md b/docs/api.md
index 5acf144..32f55e9 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -38,7 +38,6 @@
     dt.gibleed_omop
     dt.synthea27nj_omop
     dt.physionet2012
-    dt.mimic_ii
 ```
 
 ## Tools