bagel 0.5.1.dev7__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.
- bagel/__init__.py +0 -0
- bagel/cli.py +520 -0
- bagel/dictionary_models.py +164 -0
- bagel/logger.py +63 -0
- bagel/mappings.py +102 -0
- bagel/models.py +97 -0
- bagel/pipeline-catalog/.git +1 -0
- bagel/pipeline-catalog/.pre-commit-config.yaml +16 -0
- bagel/pipeline-catalog/LICENSE +21 -0
- bagel/pipeline-catalog/README.md +2 -0
- bagel/pipeline-catalog/conversion/conversion.json +20 -0
- bagel/pipeline-catalog/processing/processing.json +36 -0
- bagel/utilities/__init__.py +0 -0
- bagel/utilities/bids_utils.py +74 -0
- bagel/utilities/derivative_utils.py +138 -0
- bagel/utilities/file_utils.py +96 -0
- bagel/utilities/model_utils.py +119 -0
- bagel/utilities/pheno_utils.py +590 -0
- bagel-0.5.1.dev7.dist-info/METADATA +146 -0
- bagel-0.5.1.dev7.dist-info/RECORD +23 -0
- bagel-0.5.1.dev7.dist-info/WHEEL +4 -0
- bagel-0.5.1.dev7.dist-info/entry_points.txt +2 -0
- bagel-0.5.1.dev7.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from copy import deepcopy
|
|
5
|
+
from typing import Optional, Union
|
|
6
|
+
|
|
7
|
+
import isodate
|
|
8
|
+
import jsonschema
|
|
9
|
+
import pandas as pd
|
|
10
|
+
import pydantic
|
|
11
|
+
from typer import BadParameter
|
|
12
|
+
|
|
13
|
+
from bagel import dictionary_models, mappings
|
|
14
|
+
from bagel.logger import log_error, logger
|
|
15
|
+
from bagel.mappings import (
|
|
16
|
+
DEPRECATED_NAMESPACE_PREFIXES,
|
|
17
|
+
NB,
|
|
18
|
+
SUPPORTED_NAMESPACE_PREFIXES,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# TODO: Once we remove support for v1 annotation tool data dictionaries, revert to using this version for data dictionary schema validation
|
|
22
|
+
# DICTIONARY_SCHEMA = dictionary_models.DataDictionary.model_json_schema()
|
|
23
|
+
|
|
24
|
+
AGE_FORMATS = {
|
|
25
|
+
"float": NB.pf + ":FromFloat",
|
|
26
|
+
"int": NB.pf + ":FromInt",
|
|
27
|
+
"euro": NB.pf + ":FromEuro",
|
|
28
|
+
"bounded": NB.pf + ":FromBounded",
|
|
29
|
+
"iso8601": NB.pf + ":FromISO8601",
|
|
30
|
+
"range": NB.pf + ":FromRange",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def validate_dataset_name(name: str) -> str:
|
|
35
|
+
"""Custom validation that dataset name is not an empty string or just whitespace."""
|
|
36
|
+
if name.isspace() or name == "":
|
|
37
|
+
raise BadParameter("Dataset name cannot be an empty string.")
|
|
38
|
+
return name
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def validate_portal_uri(portal: Optional[str]) -> Optional[str]:
|
|
42
|
+
"""Custom validation that portal is a valid HttpUrl"""
|
|
43
|
+
# NOTE: We need Optional in the validation type below to account for --portal being an optional argument in the pheno command
|
|
44
|
+
try:
|
|
45
|
+
pydantic.TypeAdapter(Optional[pydantic.HttpUrl]).validate_python(
|
|
46
|
+
portal
|
|
47
|
+
)
|
|
48
|
+
except pydantic.ValidationError as err:
|
|
49
|
+
raise BadParameter(
|
|
50
|
+
"Not a valid http or https URL: "
|
|
51
|
+
f"{err.errors()[0]['msg']} \nPlease try again."
|
|
52
|
+
) from err
|
|
53
|
+
|
|
54
|
+
return portal
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_columns_about(data_dict: dict, concept: str) -> list:
|
|
58
|
+
"""
|
|
59
|
+
Returns all column names that have been annotated as "IsAbout" the desired concept.
|
|
60
|
+
Parameters
|
|
61
|
+
----------
|
|
62
|
+
data_dict: dict
|
|
63
|
+
A valid Neurobagel annotated data dictionary must be provided.
|
|
64
|
+
concept: str
|
|
65
|
+
A (shorthand) IRI for a concept that a column can be "about"
|
|
66
|
+
|
|
67
|
+
Returns
|
|
68
|
+
list
|
|
69
|
+
List of column names that are "about" the desired concept
|
|
70
|
+
|
|
71
|
+
-------
|
|
72
|
+
|
|
73
|
+
"""
|
|
74
|
+
return [
|
|
75
|
+
col
|
|
76
|
+
for col, content in get_annotated_columns(data_dict)
|
|
77
|
+
if content["Annotations"]["IsAbout"]["TermURL"] == concept
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_annotated_columns(data_dict: dict) -> list[tuple[str, dict]]:
|
|
82
|
+
"""
|
|
83
|
+
Return a list of all columns that have Neurobagel 'Annotations' in a data dictionary,
|
|
84
|
+
where each column is represented as a tuple of the column name (dictionary key from the data dictionary) and
|
|
85
|
+
properties (all dictionary contents from the data dictionary).
|
|
86
|
+
"""
|
|
87
|
+
return [
|
|
88
|
+
(col, content)
|
|
89
|
+
for col, content in data_dict.items()
|
|
90
|
+
if "Annotations" in content
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def recursive_find_values_for_key(data: dict, target: str) -> list:
|
|
95
|
+
"""
|
|
96
|
+
Recursively search for a key in a possibly nested dictionary and return a list of all values found for that key.
|
|
97
|
+
|
|
98
|
+
TODO: This function currently only considers nested dicts, and would need to be expanded if Neurobagel
|
|
99
|
+
data dictionaries grow to have controlled terms inside list objects.
|
|
100
|
+
"""
|
|
101
|
+
target_values = []
|
|
102
|
+
if isinstance(data, dict):
|
|
103
|
+
for key, value in data.items():
|
|
104
|
+
if key == target:
|
|
105
|
+
target_values.append(value)
|
|
106
|
+
else:
|
|
107
|
+
target_values.extend(
|
|
108
|
+
recursive_find_values_for_key(data=value, target=target)
|
|
109
|
+
)
|
|
110
|
+
return target_values
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def find_unsupported_namespaces_and_term_urls(
|
|
114
|
+
data_dict: dict,
|
|
115
|
+
) -> tuple[list, dict]:
|
|
116
|
+
"""
|
|
117
|
+
From a provided data dictionary, find all term URLs that contain an unsupported namespace prefix.
|
|
118
|
+
Return a tuple of unsupported prefixes and a dictionary of the offending column names and their unrecognized term URLs.
|
|
119
|
+
"""
|
|
120
|
+
unsupported_prefixes = set()
|
|
121
|
+
unrecognized_term_urls = {}
|
|
122
|
+
|
|
123
|
+
for col, content in get_annotated_columns(data_dict):
|
|
124
|
+
for col_term_url in recursive_find_values_for_key(
|
|
125
|
+
content["Annotations"], "TermURL"
|
|
126
|
+
):
|
|
127
|
+
prefix = col_term_url.split(":")[0]
|
|
128
|
+
if prefix not in SUPPORTED_NAMESPACE_PREFIXES:
|
|
129
|
+
unsupported_prefixes.add(prefix)
|
|
130
|
+
unrecognized_term_urls[col] = col_term_url
|
|
131
|
+
|
|
132
|
+
# sort the prefixes for a predictable order in the error message
|
|
133
|
+
return sorted(unsupported_prefixes), unrecognized_term_urls
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def find_deprecated_namespaces(namespaces: list) -> list:
|
|
137
|
+
"""Return the deprecated vocabulary namespace prefixes found in a list of namespace prefixes."""
|
|
138
|
+
return [ns for ns in namespaces if ns in DEPRECATED_NAMESPACE_PREFIXES]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def map_categories_to_columns(data_dict: dict) -> dict[str, list]:
|
|
142
|
+
"""
|
|
143
|
+
Maps all pre-defined Neurobagel categories (e.g. "Sex") to a list containing all column names (if any) that
|
|
144
|
+
have been linked to this category.
|
|
145
|
+
|
|
146
|
+
Returns a dictionary where the keys are the Neurobagel categories and the values are lists of column names.
|
|
147
|
+
"""
|
|
148
|
+
return {
|
|
149
|
+
cat_name: get_columns_about(data_dict, cat_iri)
|
|
150
|
+
for cat_name, cat_iri in mappings.NEUROBAGEL.items()
|
|
151
|
+
if get_columns_about(data_dict, cat_iri)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def map_tools_to_columns(data_dict: dict) -> dict[str, list]:
|
|
156
|
+
"""
|
|
157
|
+
Return a mapping of all assessment tools described in the data dictionary to the columns that
|
|
158
|
+
are mapped to it.
|
|
159
|
+
|
|
160
|
+
Returns a dictionary where the keys are the assessment tool IRIs and the values are lists of column names.
|
|
161
|
+
"""
|
|
162
|
+
out_dict = defaultdict(list)
|
|
163
|
+
for col, content in get_annotated_columns(data_dict):
|
|
164
|
+
part_of = content["Annotations"].get("IsPartOf")
|
|
165
|
+
if part_of is not None:
|
|
166
|
+
out_dict[part_of.get("TermURL")].append(col)
|
|
167
|
+
|
|
168
|
+
return out_dict
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def is_missing_value(
|
|
172
|
+
value: Union[str, int], column: str, data_dict: dict
|
|
173
|
+
) -> bool:
|
|
174
|
+
"""Determine if a raw value is listed as a missing value in the data dictionary entry for this column"""
|
|
175
|
+
return value in data_dict[column]["Annotations"].get("MissingValues", [])
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def is_column_categorical(column: str, data_dict: dict) -> bool:
|
|
179
|
+
"""Determine whether a column in a Neurobagel data dictionary is categorical"""
|
|
180
|
+
if "Levels" in data_dict[column]["Annotations"]:
|
|
181
|
+
return True
|
|
182
|
+
return False
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def map_cat_val_to_term(
|
|
186
|
+
value: Union[str, int], column: str, data_dict: dict
|
|
187
|
+
) -> str:
|
|
188
|
+
"""Take a raw categorical value and return the controlled term it has been mapped to"""
|
|
189
|
+
return data_dict[column]["Annotations"]["Levels"][value]["TermURL"]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def get_age_format(column: str, data_dict: dict) -> str:
|
|
193
|
+
return data_dict[column]["Annotations"]["Format"]["TermURL"]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def transform_age(value: str, value_format: str) -> float:
|
|
197
|
+
try:
|
|
198
|
+
if value_format in [
|
|
199
|
+
AGE_FORMATS["float"],
|
|
200
|
+
AGE_FORMATS["int"],
|
|
201
|
+
]:
|
|
202
|
+
return float(value)
|
|
203
|
+
if value_format == AGE_FORMATS["euro"]:
|
|
204
|
+
return float(value.replace(",", "."))
|
|
205
|
+
if value_format == AGE_FORMATS["bounded"]:
|
|
206
|
+
return float(value.strip("+"))
|
|
207
|
+
if value_format == AGE_FORMATS["iso8601"]:
|
|
208
|
+
if not value.startswith("P"):
|
|
209
|
+
pvalue = "P" + value
|
|
210
|
+
else:
|
|
211
|
+
pvalue = value
|
|
212
|
+
duration = isodate.parse_duration(pvalue)
|
|
213
|
+
return float(duration.years + duration.months / 12)
|
|
214
|
+
if value_format == AGE_FORMATS["range"]:
|
|
215
|
+
a_min, a_max = value.split("-")
|
|
216
|
+
return sum(map(float, [a_min, a_max])) / 2
|
|
217
|
+
log_error(
|
|
218
|
+
logger,
|
|
219
|
+
f"The provided data dictionary contains an unrecognized age format: {value_format}. "
|
|
220
|
+
f"Ensure that the format TermURL is one of {list(AGE_FORMATS.values())}.",
|
|
221
|
+
)
|
|
222
|
+
except (ValueError, isodate.isoerror.ISO8601Error) as e:
|
|
223
|
+
log_error(
|
|
224
|
+
logger,
|
|
225
|
+
f"There was a problem with applying the format {value_format} to the age: {value}. Error: {str(e)}\n"
|
|
226
|
+
f"Check that the format specified in the data dictionary ({value_format}) is correct for the age values in your phenotypic file, "
|
|
227
|
+
"and that you correctly annotated any missing values in your age column. "
|
|
228
|
+
"For examples of acceptable values for specific age formats, see https://neurobagel.org/data_models/dictionaries/#age.",
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def get_transformed_values(
|
|
233
|
+
columns: list, row: pd.Series, data_dict: dict
|
|
234
|
+
) -> list:
|
|
235
|
+
"""Convert a list of raw phenotypic values to the corresponding controlled terms, from columns that have not been annotated as being about an assessment tool."""
|
|
236
|
+
transf_vals: list[float | str] = []
|
|
237
|
+
for col in columns:
|
|
238
|
+
value = row[col]
|
|
239
|
+
if is_missing_value(value, col, data_dict):
|
|
240
|
+
continue
|
|
241
|
+
if is_column_categorical(col, data_dict):
|
|
242
|
+
transf_vals.append(map_cat_val_to_term(value, col, data_dict))
|
|
243
|
+
else:
|
|
244
|
+
# TODO: replace with more flexible solution when we have more
|
|
245
|
+
# continuous variables than just age
|
|
246
|
+
transf_vals.append(
|
|
247
|
+
transform_age(str(value), get_age_format(col, data_dict))
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
return transf_vals
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# TODO: Check all columns and then return list of offending columns' names
|
|
254
|
+
def categorical_cols_have_bids_levels(data_dict: dict) -> bool:
|
|
255
|
+
for col, content in get_annotated_columns(data_dict):
|
|
256
|
+
if (
|
|
257
|
+
is_column_categorical(col, data_dict)
|
|
258
|
+
and content.get("Levels") is None
|
|
259
|
+
):
|
|
260
|
+
return False
|
|
261
|
+
|
|
262
|
+
return True
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def get_mismatched_categorical_levels(data_dict: dict) -> list:
|
|
266
|
+
"""
|
|
267
|
+
Returns list of any categorical columns from a data dictionary that have different entries
|
|
268
|
+
for the "Levels" key between the column's BIDS and Neurobagel annotations.
|
|
269
|
+
"""
|
|
270
|
+
mismatched_cols = []
|
|
271
|
+
for col, content in get_annotated_columns(data_dict):
|
|
272
|
+
if is_column_categorical(col, data_dict):
|
|
273
|
+
known_levels = list(
|
|
274
|
+
content["Annotations"]["Levels"].keys()
|
|
275
|
+
) + content["Annotations"].get("MissingValues", [])
|
|
276
|
+
if set(content.get("Levels", {}).keys()).difference(known_levels):
|
|
277
|
+
mismatched_cols.append(col)
|
|
278
|
+
|
|
279
|
+
return mismatched_cols
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def are_any_available(columns: list, row: pd.Series, data_dict: dict) -> bool:
|
|
283
|
+
"""
|
|
284
|
+
Checks that at least one of the values in the specified columns is not a missing value.
|
|
285
|
+
This is mainly useful to determine the availability of an assessment tool
|
|
286
|
+
"""
|
|
287
|
+
return any(
|
|
288
|
+
not is_missing_value(value, column, data_dict)
|
|
289
|
+
for column, value in row[columns].items()
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def are_inputs_compatible(data_dict: dict, pheno_df: pd.DataFrame) -> bool:
|
|
294
|
+
"""
|
|
295
|
+
Determines whether the provided data dictionary and phenotypic file make sense together
|
|
296
|
+
"""
|
|
297
|
+
return all(
|
|
298
|
+
[
|
|
299
|
+
col in pheno_df.columns
|
|
300
|
+
for col, _ in get_annotated_columns(data_dict)
|
|
301
|
+
]
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def find_undefined_cat_col_values(
|
|
306
|
+
data_dict: dict, pheno_df: pd.DataFrame
|
|
307
|
+
) -> dict[str, list]:
|
|
308
|
+
"""
|
|
309
|
+
Checks that all categorical column values have annotations. Returns a dictionary containing
|
|
310
|
+
any categorical column names and specific column values not defined in the corresponding data
|
|
311
|
+
dictionary entry.
|
|
312
|
+
"""
|
|
313
|
+
all_undefined_values = {}
|
|
314
|
+
for col, content in get_annotated_columns(data_dict):
|
|
315
|
+
if is_column_categorical(col, data_dict):
|
|
316
|
+
known_values = list(
|
|
317
|
+
content["Annotations"]["Levels"].keys()
|
|
318
|
+
) + content["Annotations"].get("MissingValues", [])
|
|
319
|
+
unknown_values = []
|
|
320
|
+
for value in pheno_df[col].unique():
|
|
321
|
+
if value not in known_values:
|
|
322
|
+
unknown_values.append(value)
|
|
323
|
+
if unknown_values:
|
|
324
|
+
all_undefined_values[col] = unknown_values
|
|
325
|
+
|
|
326
|
+
return all_undefined_values
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def find_unused_missing_values(
|
|
330
|
+
data_dict: dict, pheno_df: pd.DataFrame
|
|
331
|
+
) -> dict[str, list]:
|
|
332
|
+
"""
|
|
333
|
+
Checks if missing values annotated in the data dictionary appear at least once in the phenotypic file.
|
|
334
|
+
Returns a dictionary containing any column names and annotated missing values not found in the phenotypic
|
|
335
|
+
file column.
|
|
336
|
+
"""
|
|
337
|
+
all_unused_missing_vals = {}
|
|
338
|
+
for col, content in get_annotated_columns(data_dict):
|
|
339
|
+
unused_missing_vals = []
|
|
340
|
+
for missing_val in content["Annotations"].get("MissingValues", []):
|
|
341
|
+
if missing_val not in pheno_df[col].unique():
|
|
342
|
+
unused_missing_vals.append(missing_val)
|
|
343
|
+
if unused_missing_vals:
|
|
344
|
+
all_unused_missing_vals[col] = unused_missing_vals
|
|
345
|
+
|
|
346
|
+
return all_unused_missing_vals
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def get_rows_with_empty_strings(df: pd.DataFrame, columns: list) -> list:
|
|
350
|
+
"""For specified columns, returns the indices of rows with empty strings"""
|
|
351
|
+
# NOTE: Profile this section if things get slow, transforming "" -> nan and then
|
|
352
|
+
# using .isna() will very likely be much faster
|
|
353
|
+
empty_row = df[columns].eq("").any(axis=1)
|
|
354
|
+
return list(empty_row[empty_row].index)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def construct_dictionary_schema_for_validation() -> dict:
|
|
358
|
+
"""
|
|
359
|
+
For backwards compatibility with the v1 annotation tool, we patch the data dictionary schema
|
|
360
|
+
to allow for either a 'Format' or 'Transformation' key (but not both) for continuous columns.
|
|
361
|
+
This ensures that v1 annotation tool data dictionaries still pass the initial schema validation (for now),
|
|
362
|
+
without encoding the 'Transformation' key in the updated data dictionary model itself.
|
|
363
|
+
|
|
364
|
+
TODO: Remove once we no longer support data dictionaries annotated using annotation tool v1.
|
|
365
|
+
"""
|
|
366
|
+
patched_schema = deepcopy(
|
|
367
|
+
dictionary_models.DataDictionary.model_json_schema()
|
|
368
|
+
)
|
|
369
|
+
continuous_schema = patched_schema["$defs"]["ContinuousNeurobagel"]
|
|
370
|
+
continuous_schema["properties"]["Transformation"] = deepcopy(
|
|
371
|
+
continuous_schema["properties"]["Format"]
|
|
372
|
+
)
|
|
373
|
+
if "Format" in continuous_schema.get("required", []):
|
|
374
|
+
continuous_schema["required"].remove("Format")
|
|
375
|
+
continuous_schema["oneOf"] = [
|
|
376
|
+
{"required": ["Transformation"], "not": {"required": ["Format"]}},
|
|
377
|
+
{"required": ["Format"], "not": {"required": ["Transformation"]}},
|
|
378
|
+
]
|
|
379
|
+
return patched_schema
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def validate_data_dict(data_dict: dict) -> None:
|
|
383
|
+
try:
|
|
384
|
+
jsonschema.validate(
|
|
385
|
+
data_dict, construct_dictionary_schema_for_validation()
|
|
386
|
+
)
|
|
387
|
+
except jsonschema.ValidationError as e:
|
|
388
|
+
# TODO: When *every* item in an input JSON is not schema valid,
|
|
389
|
+
# jsonschema.validate will raise a ValidationError for only *one* item among them.
|
|
390
|
+
# Weirdly, the item that is chosen can vary, possibly due to jsonschema internally processing/validating
|
|
391
|
+
# items in an inconsistent order.
|
|
392
|
+
# You can reproduce this by running `bagel pheno` on the example_invalid input pair.
|
|
393
|
+
# This isn't necessarily a problem as the error will not be wrong, but if we care about
|
|
394
|
+
# returning ALL invalid items, we may want to use something like a Draft7Validator instance instead.
|
|
395
|
+
# NOTE: If the validation error occurs at the root level (i.e., the entire JSON object fails),
|
|
396
|
+
# e.path may be empty. We have a backup descriptor "Entire document" for the offending item in this case.
|
|
397
|
+
log_error(
|
|
398
|
+
logger,
|
|
399
|
+
"The provided data dictionary is not a valid Neurobagel data dictionary. "
|
|
400
|
+
f"Entry that failed validation: {e.path[-1] if e.path else 'Entire document'}\n"
|
|
401
|
+
f"Details: {e.message}\n"
|
|
402
|
+
"Tip: Make sure that each annotated column contains an 'Annotations' key.",
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
if get_annotated_columns(data_dict) == []:
|
|
406
|
+
log_error(
|
|
407
|
+
logger,
|
|
408
|
+
"The provided data dictionary must contain at least one column with Neurobagel annotations.",
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
unsupported_namespaces, unrecognized_term_urls = (
|
|
412
|
+
find_unsupported_namespaces_and_term_urls(data_dict)
|
|
413
|
+
)
|
|
414
|
+
if unsupported_namespaces:
|
|
415
|
+
namespace_deprecation_msg = ""
|
|
416
|
+
if deprecated_namespaces := find_deprecated_namespaces(
|
|
417
|
+
unsupported_namespaces
|
|
418
|
+
):
|
|
419
|
+
namespace_deprecation_msg = (
|
|
420
|
+
f"\n\nMore info: The following vocabularies have been deprecated by Neurobagel: {deprecated_namespaces}. "
|
|
421
|
+
"Please update your data dictionary using the latest version of the annotation tool at https://annotate.neurobagel.org."
|
|
422
|
+
)
|
|
423
|
+
log_error(
|
|
424
|
+
logger,
|
|
425
|
+
f"The provided data dictionary contains unsupported vocabulary namespace prefixes: {unsupported_namespaces}\n"
|
|
426
|
+
f"Unsupported vocabularies are used for terms in the following columns' annotations: {unrecognized_term_urls}\n"
|
|
427
|
+
"Please ensure that the data dictionary only includes terms from Neurobagel recognized vocabularies. "
|
|
428
|
+
"(See https://neurobagel.org/data_models/dictionaries/.)"
|
|
429
|
+
f"{namespace_deprecation_msg}",
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
if (
|
|
433
|
+
len(
|
|
434
|
+
get_columns_about(
|
|
435
|
+
data_dict, concept=mappings.NEUROBAGEL["participant"]
|
|
436
|
+
)
|
|
437
|
+
)
|
|
438
|
+
== 0
|
|
439
|
+
):
|
|
440
|
+
log_error(
|
|
441
|
+
logger,
|
|
442
|
+
"The provided data dictionary must contain at least one column annotated as being about participant ID.",
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
# TODO: remove this validation when we start handling multiple participant and / or session ID columns
|
|
446
|
+
if (
|
|
447
|
+
len(
|
|
448
|
+
get_columns_about(
|
|
449
|
+
data_dict, concept=mappings.NEUROBAGEL["participant"]
|
|
450
|
+
)
|
|
451
|
+
)
|
|
452
|
+
> 1
|
|
453
|
+
) | (
|
|
454
|
+
len(
|
|
455
|
+
get_columns_about(
|
|
456
|
+
data_dict, concept=mappings.NEUROBAGEL["session"]
|
|
457
|
+
)
|
|
458
|
+
)
|
|
459
|
+
> 1
|
|
460
|
+
):
|
|
461
|
+
log_error(
|
|
462
|
+
logger,
|
|
463
|
+
"The provided data dictionary has more than one column about participant ID or session ID. "
|
|
464
|
+
"Please make sure that only one column is annotated for participant and session IDs.",
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
if (
|
|
468
|
+
len(get_columns_about(data_dict, concept=mappings.NEUROBAGEL["sex"]))
|
|
469
|
+
> 1
|
|
470
|
+
):
|
|
471
|
+
logger.warning(
|
|
472
|
+
"The provided data dictionary indicates more than one column about sex. "
|
|
473
|
+
"Neurobagel cannot resolve multiple sex values per subject-session, and so will only consider the first of these columns for sex data."
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
if (
|
|
477
|
+
len(get_columns_about(data_dict, concept=mappings.NEUROBAGEL["age"]))
|
|
478
|
+
> 1
|
|
479
|
+
):
|
|
480
|
+
logger.warning(
|
|
481
|
+
"The provided data dictionary indicates more than one column about age. "
|
|
482
|
+
"Neurobagel cannot resolve multiple age values per subject-session, so will only consider the first of these columns for age data."
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
if not categorical_cols_have_bids_levels(data_dict):
|
|
486
|
+
logger.warning(
|
|
487
|
+
"The data dictionary contains at least one column that looks categorical but lacks a BIDS 'Levels' attribute."
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
if mismatched_cols := get_mismatched_categorical_levels(data_dict):
|
|
491
|
+
logger.warning(
|
|
492
|
+
f"The data dictionary contains columns with mismatched levels between the BIDS and Neurobagel annotations: {mismatched_cols}"
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def check_for_duplicate_ids(data_dict: dict, pheno_df: pd.DataFrame):
|
|
497
|
+
"""Log an error if there are duplicate participant IDs or duplicate combinations of participant and session IDs, if both are present."""
|
|
498
|
+
id_columns = get_columns_about(
|
|
499
|
+
data_dict, concept=mappings.NEUROBAGEL["participant"]
|
|
500
|
+
) + get_columns_about(data_dict, concept=mappings.NEUROBAGEL["session"])
|
|
501
|
+
if pheno_df.duplicated(subset=id_columns).any():
|
|
502
|
+
log_error(
|
|
503
|
+
logger,
|
|
504
|
+
"The rows of the provided phenotypic file do not have unique combinations of participant and session IDs. "
|
|
505
|
+
"Please ensure that each row uniquely identifies one participant or one participant-session (if a column describing session is present).",
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def validate_inputs(data_dict: dict, pheno_df: pd.DataFrame) -> None:
|
|
510
|
+
"""Determines whether input data are valid"""
|
|
511
|
+
validate_data_dict(data_dict)
|
|
512
|
+
|
|
513
|
+
if not are_inputs_compatible(data_dict, pheno_df):
|
|
514
|
+
log_error(
|
|
515
|
+
logger,
|
|
516
|
+
"The provided data dictionary and phenotypic file are individually valid, "
|
|
517
|
+
"but are not compatible. Make sure that you selected the correct data "
|
|
518
|
+
"dictionary for your phenotypic file. Every column described in the data "
|
|
519
|
+
"dictionary has to have a corresponding column with the same name in the "
|
|
520
|
+
"phenotypic file",
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
check_for_duplicate_ids(data_dict, pheno_df)
|
|
524
|
+
|
|
525
|
+
undefined_cat_col_values = find_undefined_cat_col_values(
|
|
526
|
+
data_dict, pheno_df
|
|
527
|
+
)
|
|
528
|
+
if undefined_cat_col_values:
|
|
529
|
+
log_error(
|
|
530
|
+
logger,
|
|
531
|
+
"Categorical column(s) in the phenotypic file have values not annotated in the data dictionary "
|
|
532
|
+
f"(shown as <column_name>: [<undefined values>]): {undefined_cat_col_values}. "
|
|
533
|
+
"Please check that the correct data dictionary has been selected or make sure to annotate the missing values.",
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
unused_missing_values = find_unused_missing_values(data_dict, pheno_df)
|
|
537
|
+
if unused_missing_values:
|
|
538
|
+
logger.warning(
|
|
539
|
+
"The following values annotated as missing values in the data dictionary were not found "
|
|
540
|
+
"in the corresponding phenotypic file column(s) (<column_name>: [<unused missing values>]): "
|
|
541
|
+
f"{unused_missing_values}. If this is not intentional, please check your data dictionary "
|
|
542
|
+
"and phenotypic file."
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
# TODO: see if we can save ourselves the call to map_categories_to_columns here.
|
|
546
|
+
# We cannot do the call earlier in the CLI (because it might fail for data invalid dictionaries)
|
|
547
|
+
# and we need to know the column mappings in order to do the subject and session validation
|
|
548
|
+
column_map = map_categories_to_columns(data_dict)
|
|
549
|
+
columns_about_ids = column_map.get("participant", []) + column_map.get(
|
|
550
|
+
"session", []
|
|
551
|
+
)
|
|
552
|
+
if row_indices := get_rows_with_empty_strings(pheno_df, columns_about_ids):
|
|
553
|
+
log_error(
|
|
554
|
+
logger,
|
|
555
|
+
"We have detected missing values in participant or session id columns. "
|
|
556
|
+
"Please make sure that every row has a non-empty participant id (and session id where applicable). "
|
|
557
|
+
f"We found missing values in the following rows (first row is zero): {row_indices}.",
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def convert_transformation_to_format(data_dict: dict) -> dict:
|
|
562
|
+
"""
|
|
563
|
+
If the uploaded data dictionary contains any "Transformation" keys, rename the key(s) to "Format"
|
|
564
|
+
internally for downstream operations involving the data dictionary.
|
|
565
|
+
This ensures compatibility with both v1 and v2 annotation tool-generated data dictionaries.
|
|
566
|
+
|
|
567
|
+
TODO: Remove when we no longer support data dictionaries annotated using annotation tool v1.
|
|
568
|
+
"""
|
|
569
|
+
age_column_names = get_columns_about(
|
|
570
|
+
data_dict, concept=mappings.NEUROBAGEL["age"]
|
|
571
|
+
)
|
|
572
|
+
if age_column_names:
|
|
573
|
+
age_cols_using_transformation = []
|
|
574
|
+
for age_column_name in age_column_names:
|
|
575
|
+
age_annotations = data_dict[age_column_name]["Annotations"]
|
|
576
|
+
if "Transformation" in age_annotations:
|
|
577
|
+
age_cols_using_transformation.append(age_column_name)
|
|
578
|
+
age_annotations["Format"] = age_annotations.pop(
|
|
579
|
+
"Transformation"
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
if age_cols_using_transformation:
|
|
583
|
+
logger.warning(
|
|
584
|
+
f"The data dictionary contains a deprecated 'Transformation' key in the annotations for the column(s): {age_cols_using_transformation}. "
|
|
585
|
+
"This key has been replaced by 'Format'. For now, 'Transformation' will be interpreted as equivalent to 'Format', "
|
|
586
|
+
"but support for 'Transformation' will be removed in a future release. "
|
|
587
|
+
"We recommend updating your data dictionary using the latest version of the Neurobagel annotation tool."
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
return data_dict
|