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.
@@ -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