cehrbert-data 0.0.1__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.
Files changed (57) hide show
  1. __init__.py +0 -0
  2. cehrbert_data/__init__.py +0 -0
  3. cehrbert_data/apps/__init__.py +0 -0
  4. cehrbert_data/apps/generate_concept_similarity_table.py +423 -0
  5. cehrbert_data/apps/generate_hierarchical_bert_training_data.py +238 -0
  6. cehrbert_data/apps/generate_included_concept_list.py +116 -0
  7. cehrbert_data/apps/generate_information_content.py +131 -0
  8. cehrbert_data/apps/generate_required_labs.py +112 -0
  9. cehrbert_data/apps/generate_training_data.py +337 -0
  10. cehrbert_data/cohorts/__init__.py +0 -0
  11. cehrbert_data/cohorts/atrial_fibrillation.py +45 -0
  12. cehrbert_data/cohorts/cabg.py +72 -0
  13. cehrbert_data/cohorts/coronary_artery_disease.py +84 -0
  14. cehrbert_data/cohorts/covid.py +43 -0
  15. cehrbert_data/cohorts/covid_inpatient.py +84 -0
  16. cehrbert_data/cohorts/death.py +46 -0
  17. cehrbert_data/cohorts/heart_failure.py +423 -0
  18. cehrbert_data/cohorts/ischemic_stroke.py +45 -0
  19. cehrbert_data/cohorts/last_visit_discharged_home.py +35 -0
  20. cehrbert_data/cohorts/query_builder.py +153 -0
  21. cehrbert_data/cohorts/spark_app_base.py +745 -0
  22. cehrbert_data/cohorts/type_two_diabietes.py +166 -0
  23. cehrbert_data/cohorts/ventilation.py +22 -0
  24. cehrbert_data/config/__init__.py +0 -0
  25. cehrbert_data/config/output_names.py +9 -0
  26. cehrbert_data/const/__init__.py +0 -0
  27. cehrbert_data/const/__pycache__/__init__.cpython-311.pyc +0 -0
  28. cehrbert_data/const/__pycache__/common.cpython-311.pyc +0 -0
  29. cehrbert_data/const/common.py +28 -0
  30. cehrbert_data/decorators/__init__.py +0 -0
  31. cehrbert_data/decorators/__pycache__/__init__.cpython-311.pyc +0 -0
  32. cehrbert_data/decorators/__pycache__/patient_event_decorator.cpython-311.pyc +0 -0
  33. cehrbert_data/decorators/patient_event_decorator.py +759 -0
  34. cehrbert_data/prediction_cohorts/__init__.py +0 -0
  35. cehrbert_data/prediction_cohorts/afib_ischemic_stroke.py +14 -0
  36. cehrbert_data/prediction_cohorts/cad_cabg_cohort.py +19 -0
  37. cehrbert_data/prediction_cohorts/cad_hf_cohort.py +14 -0
  38. cehrbert_data/prediction_cohorts/copd_readmission.py +76 -0
  39. cehrbert_data/prediction_cohorts/covid_death.py +14 -0
  40. cehrbert_data/prediction_cohorts/covid_ventilation.py +14 -0
  41. cehrbert_data/prediction_cohorts/discharge_home_death.py +18 -0
  42. cehrbert_data/prediction_cohorts/hf_readmission.py +82 -0
  43. cehrbert_data/prediction_cohorts/hospitalization.py +100 -0
  44. cehrbert_data/prediction_cohorts/hospitalization_mortality.py +77 -0
  45. cehrbert_data/prediction_cohorts/t2dm_hf_cohort.py +14 -0
  46. cehrbert_data/queries/__init__.py +0 -0
  47. cehrbert_data/queries/measurement_unit_stats_query.py +42 -0
  48. cehrbert_data/tools/__init__.py +0 -0
  49. cehrbert_data/tools/download_omop_tables.py +141 -0
  50. cehrbert_data/utils/__init__.py +0 -0
  51. cehrbert_data/utils/spark_parse_args.py +350 -0
  52. cehrbert_data/utils/spark_utils.py +1405 -0
  53. cehrbert_data-0.0.1.dist-info/LICENSE +21 -0
  54. cehrbert_data-0.0.1.dist-info/METADATA +116 -0
  55. cehrbert_data-0.0.1.dist-info/RECORD +57 -0
  56. cehrbert_data-0.0.1.dist-info/WHEEL +5 -0
  57. cehrbert_data-0.0.1.dist-info/top_level.txt +2 -0
@@ -0,0 +1,759 @@
1
+ import math
2
+ from abc import ABC, abstractmethod
3
+ from enum import Enum
4
+ from typing import Optional, Union
5
+
6
+ import numpy as np
7
+ from pyspark.sql import DataFrame
8
+ from pyspark.sql import Window as W
9
+ from pyspark.sql import functions as F
10
+ from pyspark.sql import types as T
11
+
12
+ from cehrbert_data.const.common import CATEGORICAL_MEASUREMENT, MEASUREMENT
13
+
14
+
15
+ class AttType(Enum):
16
+ DAY = "day"
17
+ WEEK = "week"
18
+ MONTH = "month"
19
+ CEHR_BERT = "cehr_bert"
20
+ MIX = "mix"
21
+ NONE = "none"
22
+
23
+
24
+ class PatientEventDecorator(ABC):
25
+ @abstractmethod
26
+ def _decorate(self, patient_events):
27
+ pass
28
+
29
+ def decorate(self, patient_events):
30
+ decorated_patient_events = self._decorate(patient_events)
31
+ self.validate(decorated_patient_events)
32
+ return decorated_patient_events
33
+
34
+ @classmethod
35
+ def get_required_columns(cls):
36
+ return set(
37
+ [
38
+ "cohort_member_id",
39
+ "person_id",
40
+ "standard_concept_id",
41
+ "date",
42
+ "datetime",
43
+ "visit_occurrence_id",
44
+ "domain",
45
+ "concept_value",
46
+ "visit_rank_order",
47
+ "visit_segment",
48
+ "priority",
49
+ "date_in_week",
50
+ "concept_value_mask",
51
+ "mlm_skip_value",
52
+ "age",
53
+ "visit_concept_id",
54
+ "visit_start_date",
55
+ "visit_start_datetime",
56
+ "visit_concept_order",
57
+ "concept_order",
58
+ ]
59
+ )
60
+
61
+ def validate(self, patient_events: DataFrame):
62
+ actual_column_set = set(patient_events.columns)
63
+ expected_column_set = set(self.get_required_columns())
64
+ if actual_column_set != expected_column_set:
65
+ diff_left = actual_column_set - expected_column_set
66
+ diff_right = expected_column_set - actual_column_set
67
+ raise RuntimeError(
68
+ f"{self}\n"
69
+ f"actual_column_set - expected_column_set: {diff_left}\n"
70
+ f"expected_column_set - actual_column_set: {diff_right}"
71
+ )
72
+
73
+
74
+ class PatientEventBaseDecorator(PatientEventDecorator):
75
+ # output_columns = [
76
+ # 'cohort_member_id', 'person_id', 'concept_ids', 'visit_segments', 'orders',
77
+ # 'dates', 'ages', 'visit_concept_orders', 'num_of_visits', 'num_of_concepts',
78
+ # 'concept_value_masks', 'concept_values', 'mlm_skip_values',
79
+ # 'visit_concept_ids'
80
+ # ]
81
+ def __init__(self, visit_occurrence):
82
+ self._visit_occurrence = visit_occurrence
83
+
84
+ def _decorate(self, patient_events: DataFrame):
85
+ """
86
+ Patient_events contains the following columns (cohort_member_id, person_id,.
87
+
88
+ standard_concept_id, date, visit_occurrence_id, domain, concept_value)
89
+
90
+ :param patient_events:
91
+ :return:
92
+ """
93
+
94
+ # todo: create an assertion the dataframe contains the above columns
95
+
96
+ valid_visit_ids = patient_events.select("visit_occurrence_id", "cohort_member_id").distinct()
97
+
98
+ # Add visit_start_date to the patient_events dataframe and create the visit rank
99
+ visit_rank_udf = F.row_number().over(
100
+ W.partitionBy("person_id", "cohort_member_id").orderBy(
101
+ "visit_start_datetime", "is_inpatient", "expired", "visit_occurrence_id"
102
+ )
103
+ )
104
+ visit_segment_udf = F.col("visit_rank_order") % F.lit(2) + 1
105
+
106
+ # The visit records are joined to the cohort members (there could be multiple entries for the same patient)
107
+ # if multiple entries are present, we duplicate the visit records for those.
108
+ visits = (
109
+ self._visit_occurrence.join(valid_visit_ids, "visit_occurrence_id")
110
+ .select(
111
+ "person_id",
112
+ "cohort_member_id",
113
+ "visit_occurrence_id",
114
+ "visit_end_date",
115
+ F.col("visit_start_date").cast(T.DateType()).alias("visit_start_date"),
116
+ F.to_timestamp("visit_start_datetime").alias("visit_start_datetime"),
117
+ F.col("visit_concept_id").cast("int").isin([9201, 262, 8971, 8920]).cast("int").alias("is_inpatient"),
118
+ F.when(F.col("discharged_to_concept_id").cast("int") == 4216643, F.lit(1))
119
+ .otherwise(F.lit(0))
120
+ .alias("expired"),
121
+ )
122
+ .withColumn("visit_rank_order", visit_rank_udf)
123
+ .withColumn("visit_segment", visit_segment_udf)
124
+ .drop("person_id", "expired")
125
+ )
126
+
127
+ # Determine the concept order depending on the visit type. For outpatient visits, we assume the concepts to
128
+ # have the same order, whereas for inpatient visits, the concept order is determined by the time stamp.
129
+ # the concept order needs to be generated per each cohort member because the same visit could be used
130
+ # in multiple cohort's histories of the same patient
131
+ concept_order_udf = F.when(
132
+ F.col("is_inpatient") == 1,
133
+ F.dense_rank().over(W.partitionBy("cohort_member_id", "visit_occurrence_id").orderBy("datetime")),
134
+ ).otherwise(F.lit(1))
135
+
136
+ # Determine the global visit concept order for each patient, this takes both visit_rank_order and concept_order
137
+ # into account when assigning this new order.
138
+ # e.g. visit_rank_order = [1, 1, 2, 2], concept_order = [1, 1, 1, 2] -> visit_concept_order = [1, 1, 2, 3]
139
+ visit_concept_order_udf = F.dense_rank().over(
140
+ W.partitionBy("person_id", "cohort_member_id").orderBy("visit_rank_order", "concept_order")
141
+ )
142
+
143
+ # We need to set the visit_end_date as the visit_start_date for outpatient visits
144
+ # For inpatient visits, we use the original visit_end_date if available, otherwise
145
+ # we will infer the visit_end_date using the max(date) of the current visit
146
+ visit_end_date_udf = F.when(
147
+ F.col("is_inpatient") == 1,
148
+ F.coalesce(
149
+ F.col("visit_end_date"),
150
+ F.max("date").over(W.partitionBy("cohort_member_id", "visit_occurrence_id")),
151
+ ),
152
+ ).otherwise(F.col("visit_start_date"))
153
+
154
+ # We need to bound the medical event dates between visit_start_date and visit_end_date
155
+ bound_medical_event_date = F.when(
156
+ F.col("date") < F.col("visit_start_date"), F.col("visit_start_date")
157
+ ).otherwise(F.when(F.col("date") > F.col("visit_end_date"), F.col("visit_end_date")).otherwise(F.col("date")))
158
+
159
+ # We need to bound the medical event dates between visit_start_date and visit_end_date
160
+ bound_medical_event_datetime = F.when(
161
+ F.col("datetime") < F.col("visit_start_datetime"),
162
+ F.col("visit_start_datetime"),
163
+ ).otherwise(
164
+ F.when(
165
+ F.col("datetime") > F.col("visit_end_datetime"),
166
+ F.col("visit_end_datetime"),
167
+ ).otherwise(F.col("datetime"))
168
+ )
169
+
170
+ patient_events = (
171
+ patient_events.join(visits, ["cohort_member_id", "visit_occurrence_id"])
172
+ .withColumn("visit_end_date", visit_end_date_udf)
173
+ .withColumn("visit_end_datetime", F.date_add("visit_end_date", 1))
174
+ .withColumn("visit_end_datetime", F.expr("visit_end_datetime - INTERVAL 1 MINUTE"))
175
+ .withColumn("date", bound_medical_event_date)
176
+ .withColumn("datetime", bound_medical_event_datetime)
177
+ .withColumn("concept_order", concept_order_udf)
178
+ .withColumn("visit_concept_order", visit_concept_order_udf)
179
+ .drop("is_inpatient", "visit_end_date", "visit_end_datetime")
180
+ .distinct()
181
+ )
182
+
183
+ # Set the priority for the events.
184
+ # Create the week since epoch UDF
185
+ weeks_since_epoch_udf = (F.unix_timestamp("date") / F.lit(24 * 60 * 60 * 7)).cast("int")
186
+ patient_events = patient_events.withColumn("priority", F.lit(0)).withColumn(
187
+ "date_in_week", weeks_since_epoch_udf
188
+ )
189
+
190
+ # Create the concept_value_mask field to indicate whether domain values should be skipped
191
+ # As of now only measurement has values, so other domains would be skipped.
192
+ patient_events = patient_events.withColumn(
193
+ "concept_value_mask", (F.col("domain") == MEASUREMENT).cast("int")
194
+ ).withColumn(
195
+ "mlm_skip_value",
196
+ (F.col("domain").isin([MEASUREMENT, CATEGORICAL_MEASUREMENT])).cast("int"),
197
+ )
198
+
199
+ if "concept_value" not in patient_events.schema.fieldNames():
200
+ patient_events = patient_events.withColumn("concept_value", F.lit(0.0))
201
+
202
+ # (cohort_member_id, person_id, standard_concept_id, date, datetime, visit_occurrence_id, domain,
203
+ # concept_value, visit_rank_order, visit_segment, priority, date_in_week,
204
+ # concept_value_mask, mlm_skip_value, age)
205
+ return patient_events
206
+
207
+
208
+ class PatientEventAttDecorator(PatientEventDecorator):
209
+ def __init__(
210
+ self,
211
+ visit_occurrence,
212
+ include_visit_type,
213
+ exclude_visit_tokens,
214
+ att_type: AttType,
215
+ include_inpatient_hour_token: bool = False,
216
+ ):
217
+ self._visit_occurrence = visit_occurrence
218
+ self._include_visit_type = include_visit_type
219
+ self._exclude_visit_tokens = exclude_visit_tokens
220
+ self._att_type = att_type
221
+ self._include_inpatient_hour_token = include_inpatient_hour_token
222
+
223
+ def _decorate(self, patient_events: DataFrame):
224
+ if self._att_type == AttType.NONE:
225
+ return patient_events
226
+
227
+ # visits should the following columns (person_id,
228
+ # visit_concept_id, visit_start_date, visit_occurrence_id, domain, concept_value)
229
+ cohort_member_person_pair = patient_events.select("person_id", "cohort_member_id").distinct()
230
+ valid_visit_ids = patient_events.groupby(
231
+ "cohort_member_id",
232
+ "visit_occurrence_id",
233
+ "visit_segment",
234
+ "visit_rank_order",
235
+ ).agg(
236
+ F.min("visit_concept_order").alias("min_visit_concept_order"),
237
+ F.max("visit_concept_order").alias("max_visit_concept_order"),
238
+ F.min("concept_order").alias("min_concept_order"),
239
+ F.max("concept_order").alias("max_concept_order"),
240
+ )
241
+
242
+ visit_occurrence = (
243
+ self._visit_occurrence.select(
244
+ "person_id",
245
+ F.col("visit_start_date").cast(T.DateType()).alias("date"),
246
+ F.col("visit_start_date").cast(T.DateType()).alias("visit_start_date"),
247
+ F.col("visit_start_datetime").cast(T.TimestampType()).alias("visit_start_datetime"),
248
+ F.coalesce("visit_end_date", "visit_start_date").cast(T.DateType()).alias("visit_end_date"),
249
+ "visit_concept_id",
250
+ "visit_occurrence_id",
251
+ F.lit("visit").alias("domain"),
252
+ F.lit(0.0).alias("concept_value"),
253
+ F.lit(0).alias("concept_value_mask"),
254
+ F.lit(0).alias("mlm_skip_value"),
255
+ "age",
256
+ "discharged_to_concept_id",
257
+ )
258
+ .join(valid_visit_ids, "visit_occurrence_id")
259
+ .join(cohort_member_person_pair, ["person_id", "cohort_member_id"])
260
+ )
261
+
262
+ # We assume outpatient visits end on the same day, therefore we start visit_end_date to visit_start_date due
263
+ # to bad date
264
+ visit_occurrence = visit_occurrence.withColumn(
265
+ "visit_end_date",
266
+ F.when(
267
+ F.col("visit_concept_id").isin([9201, 262, 8971, 8920]),
268
+ F.col("visit_end_date"),
269
+ ).otherwise(F.col("visit_start_date")),
270
+ )
271
+
272
+ weeks_since_epoch_udf = (F.unix_timestamp("date") / F.lit(24 * 60 * 60 * 7)).cast("int")
273
+ visit_occurrence = visit_occurrence.withColumn("date_in_week", weeks_since_epoch_udf)
274
+
275
+ # Cache visit for faster processing
276
+ visit_occurrence.cache()
277
+
278
+ visits = visit_occurrence.drop("discharged_to_concept_id")
279
+
280
+ # (cohort_member_id, person_id, standard_concept_id, date, visit_occurrence_id, domain,
281
+ # concept_value, visit_rank_order, visit_segment, priority, date_in_week,
282
+ # concept_value_mask, mlm_skip_value, visit_end_date)
283
+ visit_start_events = (
284
+ visits.withColumn("date", F.col("visit_start_date"))
285
+ .withColumn("datetime", F.to_timestamp("visit_start_date"))
286
+ .withColumn("standard_concept_id", F.lit("VS"))
287
+ .withColumn("visit_concept_order", F.col("min_visit_concept_order"))
288
+ .withColumn("concept_order", F.col("min_concept_order") - 1)
289
+ .withColumn("priority", F.lit(-2))
290
+ .drop("min_visit_concept_order", "max_visit_concept_order")
291
+ .drop("min_concept_order", "max_concept_order")
292
+ )
293
+
294
+ visit_end_events = (
295
+ visits.withColumn("date", F.col("visit_end_date"))
296
+ .withColumn("datetime", F.date_add(F.to_timestamp("visit_end_date"), 1))
297
+ .withColumn("datetime", F.expr("datetime - INTERVAL 1 MINUTE"))
298
+ .withColumn("standard_concept_id", F.lit("VE"))
299
+ .withColumn("visit_concept_order", F.col("max_visit_concept_order"))
300
+ .withColumn("concept_order", F.col("max_concept_order") + 1)
301
+ .withColumn("priority", F.lit(200))
302
+ .drop("min_visit_concept_order", "max_visit_concept_order")
303
+ .drop("min_concept_order", "max_concept_order")
304
+ )
305
+
306
+ # Get the prev days_since_epoch
307
+ prev_visit_end_date_udf = F.lag("visit_end_date").over(
308
+ W.partitionBy("person_id", "cohort_member_id").orderBy("visit_rank_order")
309
+ )
310
+
311
+ # Compute the time difference between the current record and the previous record
312
+ time_delta_udf = F.when(F.col("prev_visit_end_date").isNull(), 0).otherwise(
313
+ F.datediff("visit_start_date", "prev_visit_end_date")
314
+ )
315
+
316
+ # Udf for calculating the time token
317
+ if self._att_type == AttType.DAY:
318
+ att_func = time_day_token
319
+ elif self._att_type == AttType.WEEK:
320
+ att_func = time_week_token
321
+ elif self._att_type == AttType.MONTH:
322
+ att_func = time_month_token
323
+ elif self._att_type == AttType.MIX:
324
+ att_func = time_mix_token
325
+ else:
326
+ att_func = time_token_func
327
+
328
+ time_token_udf = F.udf(att_func, T.StringType())
329
+
330
+ att_tokens = (
331
+ visits.withColumn("datetime", F.to_timestamp("date"))
332
+ .withColumn("prev_visit_end_date", prev_visit_end_date_udf)
333
+ .where(F.col("prev_visit_end_date").isNotNull())
334
+ .withColumn("time_delta", time_delta_udf)
335
+ .withColumn(
336
+ "time_delta",
337
+ F.when(F.col("time_delta") < 0, F.lit(0)).otherwise(F.col("time_delta")),
338
+ )
339
+ .withColumn("standard_concept_id", time_token_udf("time_delta"))
340
+ .withColumn("priority", F.lit(-3))
341
+ .withColumn("visit_rank_order", F.col("visit_rank_order"))
342
+ .withColumn("visit_concept_order", F.col("min_visit_concept_order"))
343
+ .withColumn("concept_order", F.lit(0))
344
+ .drop("prev_visit_end_date", "time_delta")
345
+ .drop("min_visit_concept_order", "max_visit_concept_order")
346
+ .drop("min_concept_order", "max_concept_order")
347
+ )
348
+
349
+ if self._exclude_visit_tokens:
350
+ artificial_tokens = att_tokens
351
+ else:
352
+ artificial_tokens = visit_start_events.unionByName(att_tokens).unionByName(visit_end_events)
353
+
354
+ if self._include_visit_type:
355
+ # insert visit type after the VS token
356
+ visit_type_tokens = (
357
+ visits.withColumn("standard_concept_id", F.col("visit_concept_id"))
358
+ .withColumn("datetime", F.to_timestamp("date"))
359
+ .withColumn("visit_concept_order", F.col("min_visit_concept_order"))
360
+ .withColumn("concept_order", F.lit(0))
361
+ .withColumn("priority", F.lit(-1))
362
+ .drop("min_visit_concept_order", "max_visit_concept_order")
363
+ .drop("min_concept_order", "max_concept_order")
364
+ )
365
+
366
+ artificial_tokens = artificial_tokens.unionByName(visit_type_tokens)
367
+
368
+ artificial_tokens = artificial_tokens.drop("visit_end_date")
369
+
370
+ # Retrieving the events that are ONLY linked to inpatient visits
371
+ inpatient_visits = visit_occurrence.where(F.col("visit_concept_id").isin([9201, 262, 8971, 8920])).select(
372
+ "visit_occurrence_id", "visit_end_date", "cohort_member_id"
373
+ )
374
+ inpatient_events = patient_events.join(inpatient_visits, ["visit_occurrence_id", "cohort_member_id"])
375
+
376
+ # Fill in the visit_end_date if null (because some visits are still ongoing at the time of data extraction)
377
+ # Bound the event dates within visit_start_date and visit_end_date
378
+ # Generate a span rank to indicate the position of the group of events
379
+ # Update the priority for each span
380
+ inpatient_events = (
381
+ inpatient_events.withColumn(
382
+ "visit_end_date",
383
+ F.coalesce(
384
+ "visit_end_date",
385
+ F.max("date").over(W.partitionBy("cohort_member_id", "visit_occurrence_id")),
386
+ ),
387
+ )
388
+ .withColumn(
389
+ "date",
390
+ F.when(F.col("date") < F.col("visit_start_date"), F.col("visit_start_date")).otherwise(
391
+ F.when(F.col("date") > F.col("visit_end_date"), F.col("visit_end_date")).otherwise(F.col("date"))
392
+ ),
393
+ )
394
+ .withColumn("priority", F.col("priority") + F.col("concept_order") * 0.1)
395
+ .drop("visit_end_date")
396
+ )
397
+
398
+ discharge_events = (
399
+ visit_occurrence.where(F.col("visit_concept_id").isin([9201, 262, 8971, 8920]))
400
+ .withColumn(
401
+ "standard_concept_id",
402
+ F.coalesce(F.col("discharged_to_concept_id"), F.lit(0)),
403
+ )
404
+ .withColumn("visit_concept_order", F.col("max_visit_concept_order"))
405
+ .withColumn("concept_order", F.col("max_concept_order") + 1)
406
+ .withColumn("date", F.col("visit_end_date"))
407
+ .withColumn("datetime", F.date_add(F.to_timestamp("visit_end_date"), 1))
408
+ .withColumn("datetime", F.expr("datetime - INTERVAL 1 MINUTE"))
409
+ .withColumn("priority", F.lit(100))
410
+ .drop("discharged_to_concept_id", "visit_end_date")
411
+ .drop("min_visit_concept_order", "max_visit_concept_order")
412
+ .drop("min_concept_order", "max_concept_order")
413
+ )
414
+
415
+ # Add discharge events to the inpatient visits
416
+ inpatient_events = inpatient_events.unionByName(discharge_events)
417
+
418
+ # Get the prev days_since_epoch
419
+ inpatient_prev_date_udf = F.lag("date").over(
420
+ W.partitionBy("cohort_member_id", "visit_occurrence_id").orderBy("concept_order")
421
+ )
422
+
423
+ # Compute the time difference between the current record and the previous record
424
+ inpatient_time_delta_udf = F.when(F.col("prev_date").isNull(), 0).otherwise(F.datediff("date", "prev_date"))
425
+
426
+ if self._include_inpatient_hour_token:
427
+ # Create ATT tokens within the inpatient visits
428
+ inpatient_prev_datetime_udf = F.lag("datetime").over(
429
+ W.partitionBy("cohort_member_id", "visit_occurrence_id").orderBy("concept_order")
430
+ )
431
+ # Compute the time difference between the current record and the previous record
432
+ inpatient_hour_delta_udf = F.when(F.col("prev_datetime").isNull(), 0).otherwise(
433
+ F.floor((F.unix_timestamp("datetime") - F.unix_timestamp("prev_datetime")) / 3600)
434
+ )
435
+ inpatient_att_token = F.when(
436
+ F.col("hour_delta") < 24, F.concat(F.lit("i-H"), F.col("hour_delta"))
437
+ ).otherwise(F.concat(F.lit("i-"), time_token_udf("time_delta")))
438
+ # Create ATT tokens within the inpatient visits
439
+ inpatient_att_events = (
440
+ inpatient_events.withColumn(
441
+ "is_span_boundary",
442
+ F.row_number().over(
443
+ W.partitionBy("cohort_member_id", "visit_occurrence_id", "concept_order").orderBy("priority")
444
+ ),
445
+ )
446
+ .where(F.col("is_span_boundary") == 1)
447
+ .withColumn("prev_date", inpatient_prev_date_udf)
448
+ .withColumn("time_delta", inpatient_time_delta_udf)
449
+ .withColumn("prev_datetime", inpatient_prev_datetime_udf)
450
+ .withColumn("hour_delta", inpatient_hour_delta_udf)
451
+ .where(F.col("prev_date").isNotNull())
452
+ .where(F.col("hour_delta") > 0)
453
+ .withColumn("standard_concept_id", inpatient_att_token)
454
+ .withColumn("visit_concept_order", F.col("visit_concept_order"))
455
+ .withColumn("priority", F.col("priority") - 0.01)
456
+ .withColumn("concept_value_mask", F.lit(0))
457
+ .withColumn("concept_value", F.lit(0.0))
458
+ .drop("prev_date", "time_delta", "is_span_boundary")
459
+ .drop("prev_datetime", "hour_delta")
460
+ )
461
+ else:
462
+ # Create ATT tokens within the inpatient visits
463
+ inpatient_att_events = (
464
+ inpatient_events.withColumn(
465
+ "is_span_boundary",
466
+ F.row_number().over(
467
+ W.partitionBy("cohort_member_id", "visit_occurrence_id", "concept_order").orderBy("priority")
468
+ ),
469
+ )
470
+ .where(F.col("is_span_boundary") == 1)
471
+ .withColumn("prev_date", inpatient_prev_date_udf)
472
+ .withColumn("time_delta", inpatient_time_delta_udf)
473
+ .where(F.col("time_delta") != 0)
474
+ .where(F.col("prev_date").isNotNull())
475
+ .withColumn(
476
+ "standard_concept_id",
477
+ F.concat(F.lit("i-"), time_token_udf("time_delta")),
478
+ )
479
+ .withColumn("visit_concept_order", F.col("visit_concept_order"))
480
+ .withColumn("priority", F.col("priority") - 0.01)
481
+ .withColumn("concept_value_mask", F.lit(0))
482
+ .withColumn("concept_value", F.lit(0.0))
483
+ .drop("prev_date", "time_delta", "is_span_boundary")
484
+ )
485
+
486
+ self.validate(inpatient_events)
487
+ self.validate(inpatient_att_events)
488
+
489
+ # Retrieving the events that are NOT linked to inpatient visits
490
+ other_events = patient_events.join(
491
+ inpatient_visits.select("visit_occurrence_id", "cohort_member_id"),
492
+ ["visit_occurrence_id", "cohort_member_id"],
493
+ how="left_anti",
494
+ )
495
+
496
+ patient_events = inpatient_events.unionByName(inpatient_att_events).unionByName(other_events)
497
+
498
+ self.validate(patient_events)
499
+ self.validate(artificial_tokens)
500
+
501
+ # artificial_tokens = artificial_tokens.select(sorted(artificial_tokens.columns))
502
+ return patient_events.unionByName(artificial_tokens)
503
+
504
+
505
+ class DemographicPromptDecorator(PatientEventDecorator):
506
+ def __init__(self, patient_demographic, use_age_group: bool = False):
507
+ self._patient_demographic = patient_demographic
508
+ self._use_age_group = use_age_group
509
+
510
+ def _decorate(self, patient_events: DataFrame):
511
+ if self._patient_demographic is None:
512
+ return patient_events
513
+
514
+ # set(['cohort_member_id', 'person_id', 'standard_concept_id', 'date',
515
+ # 'visit_occurrence_id', 'domain', 'concept_value', 'visit_rank_order',
516
+ # 'visit_segment', 'priority', 'date_in_week', 'concept_value_mask',
517
+ # 'mlm_skip_value', 'age', 'visit_concept_id'])
518
+
519
+ # Get the first token of the patient history
520
+ first_token_udf = F.row_number().over(
521
+ W.partitionBy("cohort_member_id", "person_id").orderBy(
522
+ "visit_start_datetime",
523
+ "visit_occurrence_id",
524
+ "priority",
525
+ "standard_concept_id",
526
+ )
527
+ )
528
+
529
+ # Identify the first token of each patient history
530
+ patient_first_token = (
531
+ patient_events.withColumn("token_order", first_token_udf)
532
+ .withColumn("concept_value_mask", F.lit(0))
533
+ .withColumn("concept_value", F.lit(0.0))
534
+ .where("token_order = 1")
535
+ .drop("token_order")
536
+ )
537
+
538
+ # Udf for identifying the earliest date associated with a visit_occurrence_id
539
+ sequence_start_year_token = (
540
+ patient_first_token.withColumn(
541
+ "standard_concept_id",
542
+ F.concat(F.lit("year:"), F.year("date").cast(T.StringType())),
543
+ )
544
+ .withColumn("priority", F.lit(-10))
545
+ .withColumn("visit_segment", F.lit(0))
546
+ .withColumn("date_in_week", F.lit(0))
547
+ .withColumn("age", F.lit(-1))
548
+ .withColumn("visit_rank_order", F.lit(0))
549
+ .withColumn("visit_concept_order", F.lit(0))
550
+ .withColumn("concept_order", F.lit(0))
551
+ )
552
+
553
+ sequence_start_year_token.cache()
554
+
555
+ if self._use_age_group:
556
+ calculate_age_group_at_first_visit_udf = F.ceil(
557
+ F.floor(F.months_between(F.col("date"), F.col("birth_datetime")) / F.lit(12) / 10)
558
+ )
559
+ age_at_first_visit_udf = F.concat(
560
+ F.lit("age:"),
561
+ (calculate_age_group_at_first_visit_udf * 10).cast(T.StringType()),
562
+ F.lit("-"),
563
+ ((calculate_age_group_at_first_visit_udf + 1) * 10).cast(T.StringType()),
564
+ )
565
+ else:
566
+ calculate_age_at_first_visit_udf = F.ceil(
567
+ F.months_between(F.col("date"), F.col("birth_datetime")) / F.lit(12)
568
+ )
569
+ age_at_first_visit_udf = F.concat(F.lit("age:"), calculate_age_at_first_visit_udf.cast(T.StringType()))
570
+
571
+ sequence_age_token = (
572
+ self._patient_demographic.select(F.col("person_id"), F.col("birth_datetime"))
573
+ .join(sequence_start_year_token, "person_id")
574
+ .withColumn("standard_concept_id", age_at_first_visit_udf)
575
+ .withColumn("priority", F.lit(-9))
576
+ .drop("birth_datetime")
577
+ )
578
+
579
+ sequence_gender_token = (
580
+ self._patient_demographic.select(F.col("person_id"), F.col("gender_concept_id"))
581
+ .join(sequence_start_year_token, "person_id")
582
+ .withColumn("standard_concept_id", F.col("gender_concept_id").cast(T.StringType()))
583
+ .withColumn("priority", F.lit(-8))
584
+ .drop("gender_concept_id")
585
+ )
586
+
587
+ sequence_race_token = (
588
+ self._patient_demographic.select(F.col("person_id"), F.col("race_concept_id"))
589
+ .join(sequence_start_year_token, "person_id")
590
+ .withColumn("standard_concept_id", F.col("race_concept_id").cast(T.StringType()))
591
+ .withColumn("priority", F.lit(-7))
592
+ .drop("race_concept_id")
593
+ )
594
+
595
+ patient_events = patient_events.unionByName(sequence_start_year_token)
596
+ patient_events = patient_events.unionByName(sequence_age_token)
597
+ patient_events = patient_events.unionByName(sequence_gender_token)
598
+ patient_events = patient_events.unionByName(sequence_race_token)
599
+
600
+ return patient_events
601
+
602
+
603
+ class DeathEventDecorator(PatientEventDecorator):
604
+ def __init__(self, death, att_type):
605
+ self._death = death
606
+ self._att_type = att_type
607
+
608
+ def _decorate(self, patient_events: DataFrame):
609
+ if self._death is None:
610
+ return patient_events
611
+
612
+ death_records = patient_events.join(self._death.select("person_id", "death_date"), "person_id")
613
+
614
+ max_visit_occurrence_id = death_records.select(F.max("visit_occurrence_id").alias("max_visit_occurrence_id"))
615
+
616
+ last_ve_record = (
617
+ death_records.where(F.col("standard_concept_id") == "VE")
618
+ .withColumn(
619
+ "record_rank",
620
+ F.row_number().over(W.partitionBy("person_id", "cohort_member_id").orderBy(F.desc("date"))),
621
+ )
622
+ .where(F.col("record_rank") == 1)
623
+ .drop("record_rank")
624
+ )
625
+
626
+ last_ve_record.cache()
627
+ last_ve_record.show()
628
+ # set(['cohort_member_id', 'person_id', 'standard_concept_id', 'date',
629
+ # 'visit_occurrence_id', 'domain', 'concept_value', 'visit_rank_order',
630
+ # 'visit_segment', 'priority', 'date_in_week', 'concept_value_mask',
631
+ # 'mlm_skip_value', 'age', 'visit_concept_id'])
632
+
633
+ artificial_visit_id = F.row_number().over(
634
+ W.partitionBy(F.lit(0)).orderBy("person_id", "cohort_member_id")
635
+ ) + F.col("max_visit_occurrence_id")
636
+ death_records = (
637
+ last_ve_record.crossJoin(max_visit_occurrence_id)
638
+ .withColumn("visit_occurrence_id", artificial_visit_id)
639
+ .withColumn("standard_concept_id", F.lit("[DEATH]"))
640
+ .withColumn("domain", F.lit("death"))
641
+ .withColumn("visit_rank_order", F.lit(1) + F.col("visit_rank_order"))
642
+ .withColumn("priority", F.lit(20))
643
+ .drop("max_visit_occurrence_id")
644
+ )
645
+
646
+ vs_records = death_records.withColumn("standard_concept_id", F.lit("VS")).withColumn("priority", F.lit(15))
647
+
648
+ ve_records = death_records.withColumn("standard_concept_id", F.lit("VE")).withColumn("priority", F.lit(30))
649
+
650
+ # Udf for calculating the time token
651
+ if self._att_type == AttType.DAY:
652
+ att_func = time_day_token
653
+ elif self._att_type == AttType.WEEK:
654
+ att_func = time_week_token
655
+ elif self._att_type == AttType.MONTH:
656
+ att_func = time_month_token
657
+ elif self._att_type == AttType.MIX:
658
+ att_func = time_mix_token
659
+ else:
660
+ att_func = time_token_func
661
+
662
+ time_token_udf = F.udf(att_func, T.StringType())
663
+
664
+ att_records = death_records.withColumn(
665
+ "death_date",
666
+ F.when(F.col("death_date") < F.col("date"), F.col("date")).otherwise(F.col("death_date")),
667
+ )
668
+ att_records = (
669
+ att_records.withColumn("time_delta", F.datediff("death_date", "date"))
670
+ .withColumn("standard_concept_id", time_token_udf("time_delta"))
671
+ .withColumn("priority", F.lit(10))
672
+ .drop("time_delta")
673
+ )
674
+
675
+ new_tokens = att_records.unionByName(vs_records).unionByName(death_records).unionByName(ve_records)
676
+ new_tokens = new_tokens.drop("death_date")
677
+ self.validate(new_tokens)
678
+
679
+ return patient_events.unionByName(new_tokens)
680
+
681
+
682
+ def time_token_func(time_delta) -> Optional[str]:
683
+ if time_delta is None or np.isnan(time_delta):
684
+ return None
685
+ if time_delta < 0:
686
+ return "W-1"
687
+ if time_delta < 28:
688
+ return f"W{str(math.floor(time_delta / 7))}"
689
+ if time_delta < 360:
690
+ return f"M{str(math.floor(time_delta / 30))}"
691
+ return "LT"
692
+
693
+
694
+ def time_day_token(time_delta):
695
+ if time_delta is None or np.isnan(time_delta):
696
+ return None
697
+ if time_delta < 1080:
698
+ return f"D{str(time_delta)}"
699
+ return "LT"
700
+
701
+
702
+ def time_week_token(time_delta):
703
+ if time_delta is None or np.isnan(time_delta):
704
+ return None
705
+ if time_delta < 1080:
706
+ return f"W{str(math.floor(time_delta / 7))}"
707
+ return "LT"
708
+
709
+
710
+ def time_month_token(time_delta):
711
+ if time_delta is None or np.isnan(time_delta):
712
+ return None
713
+ if time_delta < 1080:
714
+ return f"M{str(math.floor(time_delta / 30))}"
715
+ return "LT"
716
+
717
+
718
+ def time_mix_token(time_delta):
719
+ # WHEN day_diff <= 7 THEN CONCAT('D', day_diff)
720
+ # WHEN day_diff <= 30 THEN CONCAT('W', ceil(day_diff / 7))
721
+ # WHEN day_diff <= 360 THEN CONCAT('M', ceil(day_diff / 30))
722
+ # WHEN day_diff <= 720 THEN CONCAT('Q', ceil(day_diff / 90))
723
+ # WHEN day_diff <= 1440 THEN CONCAT('Y', ceil(day_diff / 360))
724
+ # ELSE 'LT'
725
+ if time_delta is None or np.isnan(time_delta):
726
+ return None
727
+ if time_delta <= 7:
728
+ return f"D{str(time_delta)}"
729
+ if time_delta <= 30:
730
+ # e.g. 8 -> W2
731
+ return f"W{str(math.ceil(time_delta / 7))}"
732
+ if time_delta <= 360:
733
+ # e.g. 31 -> M2
734
+ return f"M{str(math.ceil(time_delta / 30))}"
735
+ # if time_delta <= 720:
736
+ # # e.g. 361 -> Q5
737
+ # return f'Q{str(math.ceil(time_delta / 90))}'
738
+ # if time_delta <= 1080:
739
+ # # e.g. 1081 -> Y2
740
+ # return f'Y{str(math.ceil(time_delta / 360))}'
741
+ return "LT"
742
+
743
+
744
+ def get_att_function(att_type: Union[AttType, str]):
745
+ # Convert the att_type str to the corresponding enum type
746
+ if isinstance(att_type, str):
747
+ att_type = AttType(att_type)
748
+
749
+ if att_type == AttType.DAY:
750
+ return time_day_token
751
+ elif att_type == AttType.WEEK:
752
+ return time_week_token
753
+ elif att_type == AttType.MONTH:
754
+ return time_month_token
755
+ elif att_type == AttType.MIX:
756
+ return time_mix_token
757
+ elif att_type == AttType.CEHR_BERT:
758
+ return time_token_func
759
+ return None