cocoa-tokenizer 26.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cocoa/__init__.py ADDED
File without changes
cocoa/cli.py ADDED
@@ -0,0 +1,285 @@
1
+ #!/usr/bin/env python3
2
+
3
+ """
4
+ CLI for cocoa - configurable collation and tokenization
5
+ """
6
+
7
+ import pathlib
8
+ import time
9
+ from importlib.metadata import version
10
+ from typing import Annotated, Optional
11
+
12
+ import typer
13
+ from rich import print
14
+ from rich.console import Console
15
+
16
+ from cocoa.collator import Collator
17
+ from cocoa.tokenizer import Tokenizer
18
+ from cocoa.util import combine_processed_data
19
+ from cocoa.winnower import Winnower
20
+
21
+ __version__ = version("cocoa")
22
+
23
+ app = typer.Typer(
24
+ name="cocoa",
25
+ help=f"Configurable collation and tokenization (v{__version__})",
26
+ context_settings={"help_option_names": ["-h", "--help"]},
27
+ )
28
+ console = Console()
29
+
30
+
31
+ @app.command()
32
+ def collate(
33
+ collation_config: Annotated[
34
+ Optional[pathlib.Path],
35
+ typer.Option(
36
+ "--collation-config",
37
+ "-c",
38
+ help="Collation configuration file (overrides default)",
39
+ show_default=False,
40
+ ),
41
+ ] = None,
42
+ raw_data_home: Annotated[
43
+ str, typer.Option("--raw-data-home", "-r", help="Raw data directory")
44
+ ] = ...,
45
+ processed_data_home: Annotated[
46
+ str,
47
+ typer.Option("--processed-data-home", "-p", help="Processed data directory"),
48
+ ] = ...,
49
+ verbose: Annotated[
50
+ bool,
51
+ typer.Option(
52
+ "--verbose",
53
+ "-v",
54
+ help="Verbose logging for collate; this may cause "
55
+ "memory issues with large datasets",
56
+ is_flag=True,
57
+ ),
58
+ ] = False,
59
+ ):
60
+ """
61
+ Collate raw data into a denormalized format.
62
+
63
+ Reads collation configuration and produces a MEDS-like parquet file
64
+ with collated events.
65
+ """
66
+ with console.status("[bold green]Collating data..."):
67
+ t0 = time.perf_counter()
68
+ collator = Collator(
69
+ collation_cfg=collation_config,
70
+ raw_data_home=raw_data_home,
71
+ processed_data_home=processed_data_home,
72
+ )
73
+ collator.save_all(verbose=verbose)
74
+ t1 = time.perf_counter()
75
+ print(f"\n[green]✓[/green] Collation completed in {t1 - t0:.2f}s.")
76
+ out_path = collator.processed_data_home
77
+ print(f" Output: [cyan]{out_path}/meds.parquet[/cyan]")
78
+ print(f" Output: [cyan]{out_path}/subject_splits.parquet[/cyan]")
79
+
80
+
81
+ @app.command()
82
+ def tokenize(
83
+ tokenization_config: Annotated[
84
+ Optional[pathlib.Path],
85
+ typer.Option(
86
+ "--tokenization-config",
87
+ "-c",
88
+ help="Tokenization configuration file (overrides default)",
89
+ show_default=False,
90
+ ),
91
+ ] = None,
92
+ processed_data_home: Annotated[
93
+ str,
94
+ typer.Option("--processed-data-home", "-p", help="Processed data directory"),
95
+ ] = ...,
96
+ tokenizer_home: Annotated[
97
+ Optional[str],
98
+ typer.Option(
99
+ "--tokenizer-home",
100
+ "-t",
101
+ help="Use a pretrained tokenizer at this path",
102
+ show_default=False,
103
+ ),
104
+ ] = None,
105
+ verbose: Annotated[
106
+ bool,
107
+ typer.Option(
108
+ "--verbose",
109
+ "-v",
110
+ help="Verbose logging for collate; this may cause "
111
+ "memory issues with large datasets",
112
+ is_flag=True,
113
+ ),
114
+ ] = False,
115
+ ):
116
+ """
117
+ Tokenize collated data into integer sequences.
118
+
119
+ Reads collated parquet files and produces tokenized timelines with
120
+ vocabulary and bin information.
121
+ """
122
+ with console.status("[bold green]Tokenizing data..."):
123
+ t0 = time.perf_counter()
124
+ if tokenizer_home is not None:
125
+ print(f"Using pretrained tokenizer from [cyan]{tokenizer_home}[/cyan]...")
126
+ tokenizer = Tokenizer(
127
+ tokenization_cfg=tokenization_config,
128
+ processed_data_home=processed_data_home,
129
+ ).load(tokenizer_home)
130
+ else:
131
+ tokenizer = Tokenizer(
132
+ tokenization_cfg=tokenization_config,
133
+ processed_data_home=processed_data_home,
134
+ )
135
+ tokenizer.save_all(verbose=verbose)
136
+ t1 = time.perf_counter()
137
+ print(f"\n[green]✓[/green] Tokenization completed in {t1 - t0:.2f}s.")
138
+ out_path = tokenizer.processed_data_home
139
+ print(f" Output: [cyan]{out_path}/tokens_times.parquet[/cyan]")
140
+ print(f" Output: [cyan]{out_path}/tokenizer.yaml[/cyan]")
141
+ print(f" Vocabulary size: [cyan]{len(tokenizer)}[/cyan] tokens")
142
+
143
+
144
+ @app.command()
145
+ def winnow(
146
+ winnowing_config: Annotated[
147
+ Optional[pathlib.Path],
148
+ typer.Option(
149
+ "--winnowing-config",
150
+ "-c",
151
+ help="Winnowing configuration file (overrides default)",
152
+ show_default=False,
153
+ ),
154
+ ] = None,
155
+ processed_data_home: Annotated[
156
+ str,
157
+ typer.Option("--processed-data-home", "-p", help="Processed data directory"),
158
+ ] = ...,
159
+ verbose: Annotated[
160
+ bool,
161
+ typer.Option(
162
+ "--verbose",
163
+ "-v",
164
+ help="Verbose logging for winnow; prints summary statistics",
165
+ is_flag=True,
166
+ ),
167
+ ] = False,
168
+ ):
169
+ """
170
+ Winnow held-out data for evaluation.
171
+
172
+ Filters held-out timelines and assigns flags to disqualify certain subjects
173
+ from evaluation based on the configured criteria.
174
+ """
175
+ with console.status("[bold green]Winnowing data..."):
176
+ t0 = time.perf_counter()
177
+ winnower = Winnower(
178
+ winnowing_cfg=winnowing_config, processed_data_home=processed_data_home
179
+ )
180
+ winnower.save_all(verbose=verbose)
181
+ t1 = time.perf_counter()
182
+ print(f"\n[green]✓[/green] Winnowing completed in {t1 - t0:.2f}s.")
183
+ out_path = winnower.processed_data_home
184
+ for s in winnower.cfg.get("splits", ["held_out"]):
185
+ print(f" Output: [cyan]{out_path}/{s}_for_inference.parquet[/cyan]")
186
+
187
+
188
+ @app.command()
189
+ def pipeline(
190
+ collation_config: Annotated[
191
+ Optional[pathlib.Path],
192
+ typer.Option(
193
+ "--collation-config",
194
+ help="Collation configuration file (overrides default)",
195
+ show_default=False,
196
+ ),
197
+ ] = None,
198
+ tokenization_config: Annotated[
199
+ Optional[pathlib.Path],
200
+ typer.Option(
201
+ "--tokenization-config",
202
+ help="Tokenization configuration file (overrides default)",
203
+ show_default=False,
204
+ ),
205
+ ] = None,
206
+ winnowing_config: Annotated[
207
+ Optional[pathlib.Path],
208
+ typer.Option(
209
+ "--winnowing-config",
210
+ help="Winnowing configuration file (overrides default)",
211
+ show_default=False,
212
+ ),
213
+ ] = None,
214
+ raw_data_home: Annotated[
215
+ str, typer.Option("--raw-data-home", "-r", help="Raw data directory")
216
+ ] = ...,
217
+ processed_data_home: Annotated[
218
+ str,
219
+ typer.Option("--processed-data-home", "-p", help="Processed data directory"),
220
+ ] = ...,
221
+ verbose: Annotated[
222
+ bool,
223
+ typer.Option(
224
+ "--verbose", "-v", help="Verbose logging for pipeline steps", is_flag=True
225
+ ),
226
+ ] = False,
227
+ ):
228
+ """
229
+ Run the full pipeline: collate, tokenize, & winnow.
230
+ """
231
+ print("[bold]Running full pipeline[/bold]\n")
232
+ t0 = time.perf_counter()
233
+ collate(
234
+ collation_config=collation_config,
235
+ raw_data_home=raw_data_home,
236
+ processed_data_home=processed_data_home,
237
+ verbose=verbose,
238
+ )
239
+ tokenize(
240
+ tokenization_config=tokenization_config,
241
+ processed_data_home=processed_data_home,
242
+ verbose=verbose,
243
+ )
244
+ winnow(
245
+ winnowing_config=winnowing_config,
246
+ processed_data_home=processed_data_home,
247
+ verbose=verbose,
248
+ )
249
+ t1 = time.perf_counter()
250
+ print(f"\n[bold green]Pipeline completed in {t1 - t0:.2f}s.[/bold green]")
251
+
252
+
253
+ @app.command()
254
+ def combine_datasets(
255
+ input_data_dirs: list[str],
256
+ output_data_dir: Annotated[
257
+ str,
258
+ typer.Option(
259
+ "--output-data-dir", "-o", help="Output directory for combined data"
260
+ ),
261
+ ] = ...,
262
+ ):
263
+ """
264
+ Combine multiple processed datasets into one.
265
+
266
+ Merges parquet files and validates that tokenizer configurations match
267
+ across all input directories.
268
+ """
269
+ with console.status("[bold green]Combining datasets..."):
270
+ t0 = time.perf_counter()
271
+ output = combine_processed_data(
272
+ processed_data_homes=input_data_dirs, processed_data_home=output_data_dir
273
+ )
274
+ t1 = time.perf_counter()
275
+ print(f"\n[green]✓[/green] Combine completed in {t1 - t0:.2f}s.")
276
+ print(f" Output at: [cyan]{output}[/cyan]")
277
+
278
+
279
+ def main():
280
+ app()
281
+
282
+
283
+ if __name__ == "__main__":
284
+ # pipeline()
285
+ main()
cocoa/collator.py ADDED
@@ -0,0 +1,318 @@
1
+ #!/usr/bin/env python3
2
+
3
+ """
4
+ collects and collates different dataframes into a denormalized format
5
+ """
6
+
7
+ import pathlib
8
+
9
+ import numpy as np
10
+ import polars as pl
11
+
12
+ from cocoa.configurable import Configurable
13
+
14
+
15
+ class Collator(Configurable):
16
+ default_file = "collation.yaml"
17
+
18
+ def __init__(
19
+ self,
20
+ collation_cfg: pathlib.Path | str = None,
21
+ raw_data_home: pathlib.Path | str = None,
22
+ processed_data_home: pathlib.Path | str = None,
23
+ **kwargs,
24
+ ):
25
+ super().__init__(collation_cfg, **kwargs)
26
+
27
+ self.raw_data_home, self.processed_data_home = map(
28
+ lambda p: pathlib.Path(p).expanduser().resolve(),
29
+ [raw_data_home, processed_data_home],
30
+ )
31
+ self.processed_data_home.mkdir(parents=True, exist_ok=True)
32
+ self.reference_frame = None
33
+ self.splits: tuple = ("train", "tuning", "held_out")
34
+ self._tz_warned: set = set()
35
+
36
+ self.logger.info("Collator initialized...")
37
+ self.logger.info(f"{self.raw_data_home=}")
38
+ self.logger.info(f"{self.processed_data_home=}")
39
+
40
+ @staticmethod
41
+ def slightly_safer_eval(expr):
42
+ """
43
+ performs eval on polars expressions;
44
+ prevents some forms of accidental usage
45
+ but is not secure against malicious input
46
+ """
47
+ return eval(
48
+ expr,
49
+ {"__builtins__": {"str": str, "int": int, "float": float, "bool": bool}},
50
+ {"pl": pl},
51
+ )
52
+
53
+ def to_utc_naive(
54
+ self, df: pl.LazyFrame, column: str, *, table: str = None
55
+ ) -> pl.Expr:
56
+ """
57
+ expression converting `column` to a timezone-naive UTC datetime;
58
+ tz-aware columns are converted to UTC (instant-preserving),
59
+ tz-naive columns are assumed to already be UTC
60
+ """
61
+ dtype = df.collect_schema().get(column)
62
+ if isinstance(dtype, pl.Datetime) and dtype.time_zone not in (None, "UTC"):
63
+ if (key := (table, column, dtype.time_zone)) not in self._tz_warned:
64
+ self._tz_warned.add(key)
65
+ self.logger.warning(
66
+ f"{table or '<frame>'}.{column}: "
67
+ f"converting {dtype.time_zone} -> UTC"
68
+ )
69
+ return (
70
+ pl.col(column)
71
+ .dt.convert_time_zone("UTC")
72
+ .dt.replace_time_zone(time_zone=None)
73
+ )
74
+ return pl.col(column).cast(pl.Datetime).dt.replace_time_zone(time_zone=None)
75
+
76
+ def load_table(
77
+ self,
78
+ *,
79
+ table: str = None,
80
+ filter_expr: str | list = None,
81
+ agg_expr: str | list = None,
82
+ with_col_expr: str | list = None,
83
+ key: str = None,
84
+ subject_id_str: str = None,
85
+ time: str = None,
86
+ **kwargs,
87
+ ) -> pl.LazyFrame:
88
+ """
89
+ lazy-load the table `table`.parquet and perform some standard ETL
90
+ tasks in the following order if specified:
91
+ 1. fix subject_id to `subject_id_str`
92
+ 2. perform a filter operation
93
+ 3. add columns
94
+ 4. perform an aggregation by `key` or self.cfg["subfject_id"]
95
+ """
96
+ if (f := self.raw_data_home / f"{table}.parquet").exists():
97
+ df = pl.scan_parquet(f)
98
+ elif (f := self.raw_data_home / f"{table}.csv").exists():
99
+ df = pl.scan_csv(f)
100
+ else:
101
+ raise FileNotFoundError(
102
+ f"No parquet / csv file found for {table=} in {self.raw_data_home}"
103
+ )
104
+ if subject_id_str is not None:
105
+ df = df.with_columns(pl.col(subject_id_str).alias(self.cfg["subject_id"]))
106
+ if filter_expr is not None:
107
+ df = df.filter(
108
+ self.slightly_safer_eval(filter_expr)
109
+ if isinstance(filter_expr, str)
110
+ else [self.slightly_safer_eval(c) for c in filter_expr]
111
+ )
112
+ if with_col_expr is not None:
113
+ df = df.with_columns(
114
+ self.slightly_safer_eval(with_col_expr)
115
+ if isinstance(with_col_expr, str)
116
+ else [self.slightly_safer_eval(c) for c in with_col_expr]
117
+ )
118
+ if agg_expr is not None:
119
+ df = (
120
+ df.sort(time)
121
+ .group_by(key if key is not None else self.cfg["subject_id"])
122
+ .agg(
123
+ self.slightly_safer_eval(agg_expr)
124
+ if isinstance(agg_expr, str)
125
+ else [self.slightly_safer_eval(c) for c in agg_expr]
126
+ )
127
+ )
128
+ return df
129
+
130
+ def get_reference_frame(self) -> pl.LazyFrame:
131
+ """create the static reference frame as configured"""
132
+ cfg = self.cfg["reference"]
133
+ if self.reference_frame is not None: # pull from cache if available
134
+ return self.reference_frame
135
+ df = self.load_table(**cfg)
136
+ for tkv in cfg.get("augmentation_tables", []):
137
+ df = df.join(
138
+ self.load_table(**tkv),
139
+ on=tkv["key"],
140
+ validate=tkv["validation"],
141
+ how="left",
142
+ maintain_order="left",
143
+ )
144
+ for col in (cfg["start_time"], cfg["end_time"]):
145
+ df = df.with_columns(
146
+ self.to_utc_naive(df, col, table=cfg["table"]).alias(col)
147
+ )
148
+ self.reference_frame = df # cache result
149
+ return self.reference_frame
150
+
151
+ def get_entry(
152
+ self,
153
+ table: str,
154
+ time: str,
155
+ code: str,
156
+ *,
157
+ prefix: str = None,
158
+ numeric_value: str = None,
159
+ text_value: str = None,
160
+ filter_expr: str = None,
161
+ with_col_expr: str = None,
162
+ reference_key: str = None,
163
+ subject_id_str: str = None,
164
+ fix_date_to_time: bool = None,
165
+ agg_expr: str | list = None,
166
+ key: str = None,
167
+ ) -> pl.LazyFrame:
168
+ """create tokens corresponding to a configured event"""
169
+ df = (
170
+ self.get_reference_frame()
171
+ if table == "REFERENCE"
172
+ else self.load_table(
173
+ table=table,
174
+ time=time,
175
+ filter_expr=filter_expr,
176
+ with_col_expr=with_col_expr,
177
+ subject_id_str=subject_id_str,
178
+ agg_expr=agg_expr,
179
+ key=key,
180
+ )
181
+ )
182
+ if time in df.collect_schema().names():
183
+ df = df.with_columns(self.to_utc_naive(df, time, table=table).alias(time))
184
+ # otherwise `time` arrives normalized via the reference_key join below
185
+ if fix_date_to_time:
186
+ # if a date was cast to a time,
187
+ # the default of 00:00:00 should be replaced with 23:59:59
188
+ df = df.with_columns(
189
+ pl.when(pl.col(time).cast(pl.Datetime).dt.time() == pl.time(0, 0, 0))
190
+ .then(
191
+ pl.col(time)
192
+ .cast(pl.Datetime)
193
+ .dt.replace(hour=23, minute=59, second=59)
194
+ )
195
+ .otherwise(pl.col(time).cast(pl.Datetime))
196
+ .alias(time)
197
+ )
198
+ if reference_key is not None:
199
+ df = df.join(self.reference_frame, on=reference_key, how="inner").filter(
200
+ pl.col(time)
201
+ .cast(pl.Datetime)
202
+ .is_between(
203
+ pl.col(self.cfg["reference"]["start_time"]).cast(pl.Datetime),
204
+ pl.col(self.cfg["reference"]["end_time"]).cast(pl.Datetime),
205
+ )
206
+ )
207
+
208
+ return df.select(
209
+ pl.col(self.cfg["subject_id"]).cast(pl.String).alias("subject_id"),
210
+ # `time` was normalized to naive UTC when the frame was loaded above
211
+ pl.col(time).alias("time"),
212
+ pl.when(pl.col(code).is_not_null())
213
+ .then(
214
+ pl.concat_str(
215
+ [
216
+ pl.lit(prefix),
217
+ pl.col(code)
218
+ .cast(pl.String)
219
+ .str.to_lowercase()
220
+ .str.replace_all(r"\s+", "_"),
221
+ ],
222
+ separator="//",
223
+ ignore_nulls=True, # prefix is optional
224
+ )
225
+ )
226
+ .alias("code"),
227
+ (pl.col(numeric_value) if numeric_value else pl.lit(None))
228
+ .cast(pl.Float32) # dumb
229
+ .alias("numeric_value"),
230
+ (
231
+ pl.col(text_value).str.to_lowercase().str.replace_all(r"\s+", "_")
232
+ if text_value
233
+ else pl.lit(None)
234
+ )
235
+ .cast(pl.String)
236
+ .alias("text_value"),
237
+ ).drop_nulls(subset=["subject_id", "time", "code"])
238
+
239
+ def get_all(self) -> pl.LazyFrame:
240
+ """get all tokens for all events as configured"""
241
+ return pl.concat(
242
+ (self.get_entry(**entry) for entry in self.cfg.get("entries", []))
243
+ )
244
+
245
+ def get_subject_splits(self) -> pl.DataFrame:
246
+ """get the subject splits as configured"""
247
+ partition = (
248
+ self.get_reference_frame()
249
+ .group_by(self.cfg.get("group_id", self.cfg["subject_id"]))
250
+ .agg(pl.col(self.cfg.reference.start_time).min().alias("first_time"))
251
+ .sort("first_time")
252
+ .with_row_index()
253
+ ).collect()
254
+ split_idx = (
255
+ (
256
+ len(partition)
257
+ * np.cumsum(
258
+ [
259
+ self.cfg.subject_splits.train_frac,
260
+ self.cfg.subject_splits.tuning_frac,
261
+ ]
262
+ )
263
+ )
264
+ .astype(int)
265
+ .tolist()
266
+ )
267
+ partition = partition.with_columns(
268
+ split=pl.when(pl.col("index") < split_idx[0])
269
+ .then(pl.lit(self.splits[0]))
270
+ .when(pl.col("index") < split_idx[1])
271
+ .then(pl.lit(self.splits[1]))
272
+ .otherwise(pl.lit(self.splits[2]))
273
+ )
274
+ return (
275
+ partition
276
+ if "group_id" not in self.cfg
277
+ else self.get_reference_frame()
278
+ .collect()
279
+ .join(partition, on=self.cfg["group_id"])
280
+ ).select(pl.col(self.cfg["subject_id"]).alias("subject_id"), "split")
281
+
282
+ def save_all(self, verbose: bool = False):
283
+ """save collated data and subject splits to disc, optionally w/ summary stats"""
284
+ meds_path = self.processed_data_home / "meds.parquet"
285
+ meds_path.unlink(missing_ok=True)
286
+ df_all = self.get_all()
287
+ try:
288
+ df_all.sink_parquet(meds_path, engine="streaming")
289
+ except Exception as e:
290
+ self.logger.warning(f"Streaming write failed: {e}")
291
+ df_all.sink_parquet(meds_path, engine="in-memory", mkdir=True)
292
+ df_splits = self.get_subject_splits()
293
+ if "pass_through_columns" in self.cfg:
294
+ df_splits = df_splits.join(
295
+ self.reference_frame.select(
296
+ pl.col(self.cfg["subject_id"]).alias("subject_id"),
297
+ *self.cfg.pass_through_columns,
298
+ ).collect(),
299
+ on="subject_id",
300
+ how="inner",
301
+ validate="1:1",
302
+ )
303
+ df_splits.write_parquet(
304
+ self.processed_data_home / "subject_splits.parquet", mkdir=True
305
+ )
306
+
307
+ if verbose:
308
+ self.logger.summarize_meds_like(df_all, df_splits)
309
+
310
+
311
+ if __name__ == "__main__":
312
+ self = Collator(
313
+ raw_data_home="./raw-data/raw-mimic/dev/",
314
+ processed_data_home="./processed/mimic/",
315
+ )
316
+ self.save_all(verbose=True)
317
+ print(self.get_subject_splits())
318
+ # breakpoint()
@@ -0,0 +1 @@
1
+ """Packaged default configuration resources for cocoa."""