-
Notifications
You must be signed in to change notification settings - Fork 0
chore: 🍱 split REDCap data into one Parquet file per form #192
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
c865a74
e34ba7e
23b4c6a
ccff78c
9e665a5
3b0effb
8928a40
cc620fa
8c73cef
6482294
e82797a
86e0332
730aed5
908bfd0
a4f28e5
14ee138
8616731
5fa8a88
8684da1
202e43f
6987d59
4bd0c4a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,13 +14,15 @@ | |
|
|
||
| BLD_REDCAP = BLD / "redcap" | ||
| RAW_REDCAP = RAW / "redcap" | ||
| RAW_REDCAP_DATA = DirectoryNode(root_dir=RAW_REDCAP, pattern="*.csv.gz") | ||
|
|
||
| RAW_MYFOOD24 = RAW / "myfood24" | ||
|
|
||
| FIELD_METADATA_PATH = BLD_REDCAP / "field_metadata.json" | ||
| EVENT_METADATA_PATH = BLD_REDCAP / "event_metadata.json" | ||
| REPEATING_FORMS_METADATA_PATH = BLD_REDCAP / "repeating_forms_metadata.json" | ||
| FIELD_METADATA_PREPROCESSED_PATH = BLD_REDCAP / "field_metadata_preprocessed.json" | ||
| FORMS = BLD_REDCAP / "forms" | ||
|
|
||
|
|
||
| def task_download_field_metadata( | ||
|
|
@@ -50,11 +52,7 @@ def task_download_repeating_forms_metadata( | |
|
|
||
|
|
||
| def task_download_raw_redcap_data( | ||
| raw_data_dir: Annotated[ | ||
| Path, | ||
| DirectoryNode(root_dir=RAW_REDCAP, pattern="*.csv.gz"), | ||
| Product, | ||
| ], | ||
| raw_data_dir: Annotated[Path, RAW_REDCAP_DATA, Product], | ||
| ) -> None: | ||
| """Download the latest data from all centers to `RAW_REDCAP/<timestamp>.csv.gz`.""" | ||
| # TODO: Handle all centers | ||
|
|
@@ -77,6 +75,43 @@ def task_preprocess_field_metadata( | |
| common.json.write(field_metadata_preprocessed_path, field_metadata_preprocessed) | ||
|
|
||
|
|
||
| def task_split_forms( | ||
| forms_dir: Annotated[ | ||
| Path, | ||
| DirectoryNode(root_dir=FORMS, pattern="**/*.parquet"), | ||
| Product, | ||
| ], | ||
| raw_data_paths: Annotated[list[Path], RAW_REDCAP_DATA], | ||
| field_metadata_path: Path = FIELD_METADATA_PREPROCESSED_PATH, | ||
| event_metadata_path: Path = EVENT_METADATA_PATH, | ||
| repeating_forms_path: Path = REPEATING_FORMS_METADATA_PATH, | ||
| ) -> None: | ||
| """Split each batch of raw data into one Parquet file per form. | ||
|
|
||
| Written to `FORMS/<timestamp>/<form_name>.parquet`. | ||
| """ | ||
| form_to_fields = data.redcap.core.get_form_field_mapping( | ||
| common.json.read(field_metadata_path) | ||
| ) | ||
| form_to_events = data.redcap.core.get_form_event_mapping( | ||
| common.json.read(event_metadata_path) | ||
| ) | ||
| repeating_form_names = data.redcap.core.get_repeating_forms( | ||
| common.json.read(repeating_forms_path) | ||
| ) | ||
|
Comment on lines
+93
to
+101
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is the metadata we will use to split the data. Could even be the output of the previous preprocessing step. |
||
| for raw_data_path in raw_data_paths: | ||
| raw_data = data.redcap.core.read_raw(raw_data_path, form_to_fields) | ||
| forms = data.redcap.core.split_forms( | ||
| raw_data, | ||
| form_to_fields, | ||
| form_to_events, | ||
| repeating_form_names, | ||
| ) | ||
|
|
||
| for form in forms: | ||
| data.redcap.core.write_form(form, forms_dir, raw_data_path) | ||
|
|
||
|
|
||
| def task_download_myfood24_data( | ||
| myfood24_raw_data_dir: Annotated[ | ||
| Path, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,5 @@ | ||
| """REDCap data functions.""" | ||
|
|
||
| from . import raw | ||
| from . import core, raw | ||
|
|
||
| __all__ = ["raw"] | ||
| __all__ = ["core", "raw"] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,128 @@ | ||
| from collections import defaultdict | ||
| from dataclasses import dataclass | ||
| from operator import itemgetter | ||
| from pathlib import Path | ||
|
|
||
| import polars as pl | ||
| import seedcase_soil as so | ||
|
|
||
|
|
||
| @dataclass | ||
| class Form: | ||
| """Class to hold the name and data of a form.""" | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I decided to do this instead of writing the form name into each df as a separate column only to drop that column later, as it felt a bit cleaner. |
||
|
|
||
| name: str | ||
| data: pl.DataFrame | ||
|
|
||
|
|
||
| REDCAP_ID_COLS = [ | ||
| "record_id_s", | ||
| "redcap_event_name", | ||
| "redcap_repeat_instrument", | ||
| "redcap_repeat_instance", | ||
| ] | ||
|
|
||
|
|
||
| def read_raw(raw_data_path: Path, form_to_fields: dict[str, list[str]]) -> pl.LazyFrame: | ||
| """Read the raw data into a LazyFrame with missing columns added.""" | ||
| raw_lf = pl.scan_csv(raw_data_path, infer_schema=False) | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm reading into a lazy frame because the huge number of columns made the transformations very slow on data frames, even with only 2 raw batches. Lazy frames allow Polars to optimise operations a lot more. |
||
| return _with_missing_columns(raw_lf, form_to_fields) | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I decided to add missing columns here, right at read-time. This means that we don't have to worry about this later on in the flow. Can be a separate step of course. I also thought about extra columns (if a column is dropped later on in the study), but I don't think that's a problem. Any columns not in the latest metadata will not make it into staging, which feels like what we want. Let me know if anyone has wise thoughts about these scenarios. |
||
|
|
||
|
|
||
| def get_form_field_mapping( | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These mapping functions use a for loop. I think this is the simplest and cleanest way of expressing the logic. But lmk if I should rewrite it. |
||
| field_metadata: list[dict[str, str]], | ||
| ) -> dict[str, list[str]]: | ||
| """Get a mapping from form name to field names in that form.""" | ||
| mapping: dict[str, list[str]] = defaultdict(list) | ||
| for field in field_metadata: | ||
| mapping[field["form_name"]].append(field["field_name"]) | ||
|
|
||
| return mapping | ||
|
|
||
|
|
||
| def get_form_event_mapping( | ||
| event_metadata: list[dict[str, str]], | ||
| ) -> dict[str, list[str]]: | ||
| """Get a mapping from form name to event names where the form is filled in.""" | ||
| mapping: dict[str, list[str]] = defaultdict(list) | ||
| for item in event_metadata: | ||
| mapping[item["form"]].append(item["unique_event_name"]) | ||
|
|
||
| return mapping | ||
|
|
||
|
|
||
| def get_repeating_forms(repeating_forms: list[dict[str, str]]) -> set[str]: | ||
| """Get the set of repeating form names.""" | ||
| return set(so.fmap(repeating_forms, itemgetter("form_name"))) | ||
|
|
||
|
|
||
| def split_forms( | ||
| raw_lf: pl.LazyFrame, | ||
| form_to_fields: dict[str, list[str]], | ||
| form_to_events: dict[str, list[str]], | ||
| repeating_form_names: set[str], | ||
| ) -> list[Form]: | ||
| """Split the raw data into one dataframe per form.""" | ||
| forms = so.fmap( | ||
| form_to_fields.items(), | ||
| lambda form_entry: _create_df_for_form( | ||
| form_entry, raw_lf, form_to_events, repeating_form_names | ||
| ), | ||
| ) | ||
| return so.keep(forms, lambda form: not form.data.is_empty()) | ||
|
|
||
|
|
||
| def write_form(form: Form, forms_dir: Path, raw_data_path: Path) -> None: | ||
| """Write the dataframe.""" | ||
| timestamp = raw_data_path.name.removesuffix(".csv.gz") | ||
| file_path = forms_dir / timestamp / f"{form.name}.parquet" | ||
| file_path.parent.mkdir(parents=True, exist_ok=True) | ||
| form.data.write_parquet(file_path) | ||
|
|
||
|
|
||
| def _with_missing_columns( | ||
| lf: pl.LazyFrame, form_to_fields: dict[str, list[str]] | ||
| ) -> pl.LazyFrame: | ||
| """Add any missing metadata fields as columns in the dataframe.""" | ||
| metadata_fields = so.flat_fmap(form_to_fields.values(), lambda fields: fields) | ||
| data_fields = set(lf.collect_schema().names()) | ||
| missing_fields = so.keep(metadata_fields, lambda field: field not in data_fields) | ||
| return lf.with_columns(so.fmap(missing_fields, pl.lit(None).alias)) | ||
|
|
||
|
|
||
| def _create_df_for_form( | ||
| form_entry: tuple[str, list[str]], | ||
| raw_lf: pl.LazyFrame, | ||
| form_to_events: dict[str, list[str]], | ||
| repeating_form_names: set[str], | ||
| ) -> Form: | ||
| form_name, field_names = form_entry | ||
| events = form_to_events.get(form_name, []) | ||
| is_repeating = form_name in repeating_form_names | ||
| content_fields = so.keep(field_names, lambda field: field not in REDCAP_ID_COLS) | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @signekb maybeee removing the admin fields could be part of this? We will include only the |
||
|
|
||
| columns = [ | ||
| pl.col("record_id_s").alias("participant_id"), | ||
| pl.col("redcap_event_name").alias("event_id"), | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is where the other ids would be set up. (See PR description.) |
||
| *so.fmap(content_fields, pl.col), | ||
| # TODO: handle different centers | ||
| pl.lit("Copenhagen").alias("center"), | ||
| ] | ||
|
|
||
| if is_repeating: | ||
| # Submissions for the same participant at the same event are told apart by | ||
| # `redcap_repeat_instance`. | ||
| columns.insert( | ||
| 2, pl.col("redcap_repeat_instance").cast(pl.String).alias("submission_id") | ||
| ) | ||
|
|
||
| filters = [ | ||
| # Keep only rows coming from events where the form was filled in | ||
| pl.col("redcap_event_name").is_in(events), | ||
| # Keep only non-empty rows | ||
| pl.any_horizontal( | ||
| so.fmap(content_fields, lambda field: pl.col(field).is_not_null()) | ||
| ), | ||
| ] | ||
|
|
||
| return Form(name=form_name, data=raw_lf.filter(filters).select(columns).collect()) | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is where the data frame is materialised (i.e. the operations are executed). |
||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I moved some complexity into this function as we wanted these tasks to do orchestration rather than just call another function that does the orchestration.
I do all reading and writing here to match previous tasks.