From ce399bff51b86eca39e6bd5f2c5112868ee10384 Mon Sep 17 00:00:00 2001 From: levisstrauss Date: Mon, 24 Nov 2025 17:08:39 -0500 Subject: [PATCH 1/5] Add TPC model for length of stay prediction - Implement Temporal Pointwise Convolutional Networks (Rocheteau et al., CHIL 2021) - Add comprehensive example with synthetic ICU data - Add a comprehensive tcp_example note that show how to run the entire pipeline - Full PyHealth integration with standard MSE loss --- examples/tpc_example.ipynb | 1524 +++++++++++++++++++++++++++++++++++ pyhealth/models/__init__.py | 3 +- pyhealth/models/tpc.py | 628 +++++++++++++++ 3 files changed, 2154 insertions(+), 1 deletion(-) create mode 100644 examples/tpc_example.ipynb create mode 100644 pyhealth/models/tpc.py diff --git a/examples/tpc_example.ipynb b/examples/tpc_example.ipynb new file mode 100644 index 000000000..17b787104 --- /dev/null +++ b/examples/tpc_example.ipynb @@ -0,0 +1,1524 @@ +{ + "cells": [ + { + "metadata": { + "ExecuteTime": { + "end_time": "2025-11-24T21:36:03.672380Z", + "start_time": "2025-11-24T21:36:03.670392Z" + } + }, + "cell_type": "code", + "source": [ + "# Temporal Pointwise Convolutional Networks (TPC) Example\n", + "# Complete tutorial for using TPC with PyHealth\n", + "\n", + "\"\"\"\n", + "TPC Model Example for PyHealth\n", + "===============================\n", + "\n", + "This example demonstrates how to use the Temporal Pointwise Convolutional Networks (TPC)\n", + "model for length of stay prediction in healthcare settings.\n", + "\n", + "Paper: \"Temporal Pointwise Convolutional Networks for Length of Stay Prediction\n", + " in the Intensive Care Unit\" (Rocheteau et al., CHIL 2021)\n", + "Paper Link: https://arxiv.org/abs/2101.10043\n", + "\n", + "Implementation Authors: Zakaria Coulibaly\n", + "NetID: zakaria5\n", + "\n", + "Key Features:\n", + "- Handles irregular time sampling naturally\n", + "- Multi-scale temporal pattern recognition\n", + "- Feature interactions via pointwise convolutions\n", + "- Suitable for ICU and EHR data\n", + "\"\"\"" + ], + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "code", + "outputs": [], + "execution_count": null, + "source": [ + "# ==============================================================================\n", + "# 1. SET UP\n", + "# ==============================================================================" + ] + }, + { + "cell_type": "code", + "metadata": { + "ExecuteTime": { + "end_time": "2025-11-24T21:36:51.541456Z", + "start_time": "2025-11-24T21:36:37.962372Z" + } + }, + "source": [ + "import os\n", + "import warnings\n", + "warnings.filterwarnings('ignore')\n", + "\n", + "import torch\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score\n", + "\n", + "from pyhealth.datasets import SampleDataset, split_by_patient, get_dataloader\n", + "from pyhealth.models import TPC\n", + "from pyhealth.trainer import Trainer\n", + "from pyhealth.processors import SequenceProcessor\n", + "\n", + "print(f\"PyTorch version: {torch.__version__}\")\n", + "print(f\"CUDA available: {torch.cuda.is_available()}\")\n", + "if torch.cuda.is_available():\n", + " print(f\"CUDA device: {torch.cuda.get_device_name(0)}\")" + ], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "PyTorch version: 2.2.2\n", + "CUDA available: False\n" + ] + } + ], + "execution_count": 1 + }, + { + "metadata": {}, + "cell_type": "code", + "outputs": [], + "execution_count": null, + "source": [ + "# ==============================================================================\n", + "# 2. DATA PREPARATION\n", + "# ==============================================================================" + ] + }, + { + "cell_type": "code", + "metadata": { + "ExecuteTime": { + "end_time": "2025-11-24T21:37:01.013944Z", + "start_time": "2025-11-24T21:37:00.869851Z" + } + }, + "source": [ + "def create_synthetic_icu_data(num_samples=1000, seed=42):\n", + " \"\"\"\n", + " Create synthetic ICU patient data for demonstration.\n", + "\n", + " In production, replace this with your actual data loading logic.\n", + " Example data sources:\n", + " - MIMIC-III/MIMIC-IV\n", + " - eICU\n", + " - Your institution's EHR data\n", + "\n", + " Args:\n", + " num_samples (int): Number of patient samples to generate\n", + " seed (int): Random seed for reproducibility\n", + "\n", + " Returns:\n", + " list: List of patient sample dictionaries\n", + " \"\"\"\n", + " np.random.seed(seed)\n", + "\n", + " samples = []\n", + " for i in range(num_samples):\n", + " # Random number of diagnosis codes (1-10)\n", + " num_conditions = np.random.randint(1, 11)\n", + " conditions = np.random.randint(1, 100, size=num_conditions).tolist()\n", + "\n", + " # Random number of procedure codes (0-5)\n", + " num_procedures = np.random.randint(0, 6)\n", + " procedures = (np.random.randint(1, 50, size=num_procedures).tolist()\n", + " if num_procedures > 0 else [0])\n", + "\n", + " # Length of stay (1-30 days), correlated with number of conditions\n", + " # This simulates the clinical reality where sicker patients stay longer\n", + " base_los = 2 + num_conditions * 0.5\n", + " los = max(1.0, base_los + np.random.randn() * 2)\n", + "\n", + " samples.append({\n", + " \"patient_id\": f\"patient-{i}\",\n", + " \"visit_id\": f\"visit-{i}\",\n", + " \"conditions\": conditions,\n", + " \"procedures\": procedures,\n", + " \"label\": los\n", + " })\n", + "\n", + " return samples\n", + "\n", + "# Create synthetic dataset\n", + "print(\"Creating synthetic ICU dataset...\")\n", + "samples = create_synthetic_icu_data(num_samples=1000)\n", + "\n", + "\n", + "# Create PyHealth SampleDataset\n", + "dataset = SampleDataset(\n", + " samples=samples,\n", + " dataset_name=\"icu_los_prediction\",\n", + " task_name=\"length_of_stay\",\n", + " input_schema={\n", + " \"conditions\": SequenceProcessor, # ICD codes or similar\n", + " \"procedures\": SequenceProcessor # CPT codes or similar\n", + " },\n", + " output_schema={\"label\": \"regression\"} # Continuous LoS prediction\n", + ")\n", + "\n", + "print(f\"Dataset created with {len(dataset)} samples\")\n", + "print(f\"Sample example:\", dataset.samples[0])" + ], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Creating synthetic ICU dataset...\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Processing samples: 100%|██████████| 1000/1000 [00:00<00:00, 29797.34it/s]" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Dataset created with 1000 samples\n", + "Sample example: {'patient_id': 'patient-0', 'visit_id': 'visit-0', 'conditions': tensor([1, 2, 3, 4, 5, 6, 7]), 'procedures': tensor([1, 2]), 'label': tensor([3.7295])}\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "\n" + ] + } + ], + "execution_count": 2 + }, + { + "metadata": {}, + "cell_type": "code", + "outputs": [], + "execution_count": null, + "source": [ + "# ==============================================================================\n", + "# 3. DATA ANALYSIS\n", + "# ==============================================================================" + ] + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2025-11-24T21:37:06.340497Z", + "start_time": "2025-11-24T21:37:06.323078Z" + } + }, + "cell_type": "code", + "source": [ + "# Analyze dataset statistics\n", + "labels = np.array([sample[\"label\"] for sample in dataset.samples]).ravel()\n", + "num_conditions = np.array([len(sample[\"conditions\"]) for sample in dataset.samples]).ravel()\n", + "num_procedures = np.array([len(sample[\"procedures\"]) for sample in dataset.samples]).ravel()\n", + "\n", + "print(\"\\nDataset Statistics:\")\n", + "print(\"-\" * 40)\n", + "print(f\"Total samples: {len(dataset)}\")\n", + "print(f\"\\nLength of Stay:\")\n", + "print(f\" Mean: {np.mean(labels):.2f} days\")\n", + "print(f\" Median: {np.median(labels):.2f} days\")\n", + "print(f\" Std: {np.std(labels):.2f} days\")\n", + "print(f\" Range: [{np.min(labels):.2f}, {np.max(labels):.2f}]\")\n", + "print(f\"\\nConditions per patient:\")\n", + "print(f\" Mean: {np.mean(num_conditions):.2f}\")\n", + "print(f\" Range: [{np.min(num_conditions)}, {np.max(num_conditions)}]\")\n", + "print(f\"\\nProcedures per patient:\")\n", + "print(f\" Mean: {np.mean(num_procedures):.2f}\")\n", + "print(f\" Range: [{np.min(num_procedures)}, {np.max(num_procedures)}]\")" + ], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Dataset Statistics:\n", + "----------------------------------------\n", + "Total samples: 1000\n", + "\n", + "Length of Stay:\n", + " Mean: 4.82 days\n", + " Median: 4.86 days\n", + " Std: 2.15 days\n", + " Range: [1.00, 12.74]\n", + "\n", + "Conditions per patient:\n", + " Mean: 5.43\n", + " Range: [1, 10]\n", + "\n", + "Procedures per patient:\n", + " Mean: 2.72\n", + " Range: [1, 5]\n" + ] + } + ], + "execution_count": 3 + }, + { + "metadata": {}, + "cell_type": "code", + "outputs": [], + "execution_count": null, + "source": [ + "# ==============================================================================\n", + "# 4. DATA VISUALIZATION\n", + "# ==============================================================================" + ] + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2025-11-24T21:37:10.879136Z", + "start_time": "2025-11-24T21:37:10.415944Z" + } + }, + "cell_type": "code", + "source": [ + "labels = np.array(labels).ravel()\n", + "num_conditions = np.array(num_conditions).ravel()\n", + "num_procedures = np.array(num_procedures).ravel()\n", + "\n", + "fig, axes = plt.subplots(1, 3, figsize=(15, 4))\n", + "\n", + "# Length of stay distribution\n", + "axes[0].hist(labels, bins=30, edgecolor='black', alpha=0.7, color='steelblue')\n", + "axes[0].set_xlabel('Length of Stay (days)')\n", + "axes[0].set_ylabel('Frequency')\n", + "axes[0].set_title('Distribution of Length of Stay')\n", + "axes[0].grid(True, alpha=0.3)\n", + "\n", + "# Number of conditions\n", + "axes[1].hist(num_conditions, bins=10, edgecolor='black', alpha=0.7, color='coral')\n", + "axes[1].set_xlabel('Number of Conditions')\n", + "axes[1].set_ylabel('Frequency')\n", + "axes[1].set_title('Distribution of Condition Count')\n", + "axes[1].grid(True, alpha=0.3)\n", + "\n", + "# Number of procedures\n", + "axes[2].hist(num_procedures, bins=6, edgecolor='black', alpha=0.7, color='lightgreen')\n", + "axes[2].set_xlabel('Number of Procedures')\n", + "axes[2].set_ylabel('Frequency')\n", + "axes[2].set_title('Distribution of Procedure Count')\n", + "axes[2].grid(True, alpha=0.3)\n", + "\n", + "plt.tight_layout()\n", + "plt.show()" + ], + "outputs": [ + { + "data": { + "text/plain": [ + "
" + ], + "image/png": "iVBORw0KGgoAAAANSUhEUgAABdEAAAGGCAYAAACUkchWAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjkuMiwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8hTgPZAAAACXBIWXMAAA9hAAAPYQGoP6dpAACpGklEQVR4nOzdfXxT9f3//+dJoWkLKRelTYsUqFhQLlQUP4g6AblQvJiKDh1eoOLGbyCzAuKQ6YrDIvUj4kBgbgioH8RtitM5HTgFPw78CkxU0A8glIpIacHSpNCmFzm/P7oGQhuaJmmTtI/77dYb5OS8z3m9X8npu3nlnPcxTNM0BQAAAAAAAAAA6rCEOwAAAAAAAAAAACIVRXQAAAAAAAAAAHygiA4AAAAAAAAAgA8U0QEAAAAAAAAA8IEiOgAAAAAAAAAAPlBEBwAAAAAAAADAB4roAAAAAAAAAAD4QBEdAAAAAAAAAAAfKKIDAAAAAAAAAOADRXSE3MqVK2UYhucnLi5OqampGj58uObNm6fCwsI6bbKzs2UYRqP2c+LECWVnZ2vDhg2Nalffvnr27Knrr7++UdtpyOrVq7Vw4cJ6nzMMQ9nZ2SHdX6j985//1KBBg9SuXTsZhqE333yz3vX2798vwzD03//9380bYCPk5OTUG3/te3Xr1q1NHsNrr72mfv36KT4+XoZhaPv27T7X/frrr3XXXXfp7LPPVlxcnLp06aKLLrpIDzzwgBwOh2e9M73HAOBMGKtrtJaxutbhw4f1q1/9SgMGDFD79u0VFxenzMxMPfjgg9qzZ0/zBH2a0/Nc+97cv3+/Z1kkv05ffPGF7r33XmVkZCguLk7t27fXRRddpNzcXP3www9hi+tU/L0AtByM3zUieVzwR2M/a9f+WCwWJSUl6dprr9XmzZubN+gQqG+MjxT79u3TAw88oN69eys+Pl4JCQnq16+ffv3rX+vgwYPhDk+S9Pe//z3i39stXZtwB4CWa8WKFTr33HNVWVmpwsJCffzxx5o/f77++7//W6+99ppGjhzpWff+++/XNddc06jtnzhxQnPmzJEkDRs2zO92gewrEKtXr9aOHTuUlZVV57nNmzerW7duTR5DoEzT1Lhx49S7d2+99dZbateunfr06RPusAKWk5OjW2+9VTfddFNY9l9UVKS77rpL11xzjZYsWSKr1arevXvXu+5nn32myy+/XOedd54ef/xx9ezZU0eOHNHnn3+uNWvWaMaMGUpMTJR05vcYAPiDsbr1jNWffvqprr/+epmmqQceeEBDhgxRbGysdu3apVdeeUX/9V//peLi4mbsQf2uu+46bd68WWlpaZ5lkfo6/eEPf9DkyZPVp08fPfzww+rbt68qKyu1detWLVu2TJs3b9batWvDEtup+HsBaHkYvyNzXPBHIJ+1p06dqvHjx6u6ulo7d+7UnDlzNHz4cG3evFkDBw5spshbrr/97W+6/fbb1aVLFz3wwAMaOHCgDMPQl19+qRdffFHvvPOOPvvss3CHqb///e96/vnnKaSHEUV0NJn+/ftr0KBBnse33HKLHnroIV1xxRUaO3as9uzZI7vdLknq1q1bkw90J06cUEJCQrPsqyGXXnppWPffkO+//14//PCDbr75Zo0YMSLc4US93bt3q7KyUnfeeaeGDh16xnUXLlwoi8WiDRs2yGazeZbfeuut+u1vfyvTNJs6XACtCGO1by1prHY4HLrxxhsVFxenTZs2eeV22LBhmjRpkv7yl780dch+SU5OVnJyst/rh+t12rx5s37xi19o1KhRevPNN2W1Wj3PjRo1StOnT9d7770XltgAtHyM3761pPG7Vvfu3T39uvzyy3XOOedoxIgRWrJkif7whz/U26asrExxcXGNvgohmoSij3l5ebr99tvVu3dvffjhh+rQoYPnuauuukq//OUvI+ILcUQGpnNBs+revbueeeYZOZ1O/f73v/csr++yrw8++EDDhg1TUlKS4uPj1b17d91yyy06ceKE9u/f7/mANWfOHM/lTffcc4/X9v7973/r1ltvVadOndSrVy+f+6q1du1anX/++YqLi9PZZ5+t3/3ud17P+7r8aMOGDTIMw3O527Bhw/TOO+8oPz/f6/KrWvVdYrZjxw7deOON6tSpk+Li4nThhRdq1apV9e7n1Vdf1ezZs9W1a1clJiZq5MiR2rVrl+/En+Ljjz/WiBEjZLPZlJCQoMsuu0zvvPOO5/ns7GzPHz6PPPKIDMNQz549/dr2mTgcDs2YMUMZGRmKjY3VWWedpaysLB0/ftxrPcMw9MADD+jll1/Weeedp4SEBF1wwQX629/+Vmebf/3rX3X++efLarXq7LPP1nPPPVfn9TUMQ8ePH9eqVas8r8PpZ1M4nU794he/UJcuXZSUlKSxY8fq+++/96tfb731loYMGaKEhATZbDaNGjXK69K6e+65R1dccYUk6bbbbqt3/6c6evSoEhMT1b59+3qfr+1bQ++xOXPmaPDgwercubMSExN10UUXafny5V5F+IkTJ6pz5846ceJEnf1cddVV6tevn185ANCyMFbXaElj9R/+8AcVFBQoNzfXZ3Hj1ltv9Xrc0PhWG4dhGNq5c6d++tOfqkOHDrLb7brvvvtUUlLita7D4dDPfvYzJSUlqX379rrmmmu0e/fuOnGc/vpF6uuUk5MjwzD0wgsveBXQa8XGxurHP/6x57Hb7VZubq7OPfdcWa1WpaSk6O6779Z3333n1a5nz56eY+RUw4YN8/r7wd/4G8ofgJaD8btGSxq/faktqOfn50s6mbt169bpvvvuU3JyshISEuRyufwefyTpvffe04gRI9ShQwclJCTovPPO07x587zW2bp1q3784x+rc+fOiouL08CBA/WnP/2pzrY++eQTXX755YqLi1PXrl01a9YsVVZW1lnP1/Q7p4+HZ+qjVDN96pAhQ9SuXTu1b99eV199tV9njy9YsEDHjx/XkiVLvArop8Y3duxYr2UvvviiLrjgAsXFxalz5866+eab9fXXX3utc/q4Xeuee+7xes1PnR53wYIFysjIUPv27TVkyBB98sknXu2ef/55T0y1P5E4NU5LRhEdze7aa69VTEyMPvroI5/r7N+/X9ddd51iY2P14osv6r333tNTTz2ldu3aqaKiQmlpaZ6zeyZOnKjNmzdr8+bNeuyxx7y2M3bsWJ1zzjn685//rGXLlp0xru3btysrK0sPPfSQ1q5dq8suu0wPPvhgQHN9L1myRJdffrlSU1M9sZ1pzrJdu3bpsssu086dO/W73/1Ob7zxhvr27at77rlHubm5ddZ/9NFHlZ+frz/+8Y964YUXtGfPHt1www2qrq4+Y1wbN27UVVddpZKSEi1fvlyvvvqqbDabbrjhBr322muSai7Be+ONNyTVXDYWikuRT5w4oaFDh2rVqlX65S9/qXfffVePPPKIVq5cqR//+Md1zq5+5513tHjxYj3xxBN6/fXXPQPTvn37POu89957Gjt2rJKSkvTaa68pNzdXr776ap0/hjZv3qz4+HjPvHGbN2/WkiVLvNa5//771bZtW61evVq5ubnasGGD7rzzzgb7tXr1at14441KTEzUq6++quXLl6u4uFjDhg3Txx9/LEl67LHHPINdTk5Ovfs/1ZAhQ3To0CHdcccd2rhxo8rKyupdr6H32P79+zVp0iT96U9/0htvvKGxY8dq6tSp+u1vf+tZ58EHH1RxcbFWr17tte2vvvpKH374oaZMmdJgDgC0TIzVdUXzWL1u3TrFxMTohhtu8Cc1fo1vp7rlllvUu3dvvf766/rVr36l1atX66GHHvI8b5qmbrrpJr388suaPn261q5dq0svvVRjxoxpMJZIfJ2qq6v1wQcf6OKLL1Z6enqDfZCkX/ziF3rkkUc0atQovfXWW/rtb3+r9957T5dddpmOHDni1zbq01D8jc0fgOjG+F1XNI/fvnzzzTeSVOfKrfvuu09t27bVyy+/rL/85S9q27at3+PP8uXLde2118rtdmvZsmV6++239ctf/tKr2P7hhx/q8ssv17Fjx7Rs2TL99a9/1YUXXqjbbrtNK1eu9Kz31VdfacSIETp27JhWrlypZcuW6bPPPtPcuXMb3dfT1dfHnJwc/fSnP1Xfvn31pz/9SS+//LKcTqd+9KMf6auvvjrj9tatWye73e73FQzz5s3TxIkT1a9fP73xxht67rnn9MUXX2jIkCFB3V/m+eef1/r167Vw4UL9z//8j44fP65rr73Wc1LCY4895jnh4dT3/anT36EZmECIrVixwpRkbtmyxec6drvdPO+88zyPf/Ob35invh3/8pe/mJLM7du3+9xGUVGRKcn8zW9+U+e52u09/vjjPp87VY8ePUzDMOrsb9SoUWZiYqJ5/Phxr77l5eV5rffhhx+akswPP/zQs+y6664ze/ToUW/sp8d9++23m1ar1fz222+91hszZoyZkJBgHjt2zGs/1157rdd6f/rTn0xJ5ubNm+vdX61LL73UTElJMZ1Op2dZVVWV2b9/f7Nbt26m2+02TdM08/LyTEnm008/fcbt+bvuvHnzTIvFUuc9Ufs6//3vf/csk2Ta7XbT4XB4lhUUFJgWi8WcN2+eZ9kll1xipqenmy6Xy7PM6XSaSUlJdV7fdu3amRMmTKgTV+3rOXnyZK/lubm5piTz0KFDPvtUXV1tdu3a1RwwYIBZXV3tFUNKSop52WWXeZbVvm5//vOffW6vVnl5uXnTTTeZkkxJZkxMjDlw4EBz9uzZZmFhode6Z3qPnR5rZWWl+cQTT5hJSUme19k0TXPo0KHmhRde6LX+L37xCzMxMdHrfQKgZWGsrtFaxupzzz3XTE1NbXA902zc+Fb7OuXm5nptY/LkyWZcXJwn1nfffdeUZD733HNe6z355JN18lzf6xdpr1NBQYEpybz99tt9rnOqr7/+ut6/N/7f//t/piTz0Ucf9Szr0aNHvX+zDB061Bw6dKjncWPi9/fvBQCRj/G7RqSNC6bZtJ+158+fb1ZWVprl5eXmtm3bzEsuucSUZL7zzjumaZ7M3d133+3V3t/xx+l0momJieYVV1zh9VnxdOeee645cOBAs7Ky0mv59ddfb6alpXn+brjtttvM+Ph4s6CgwCsX5557bp3X2Nf77PTx0Fcfv/32W7NNmzbm1KlTvZY7nU4zNTXVHDdunM/+mKZpxsXFmZdeeukZ16lVXFxsxsfH13mPfPvtt6bVajXHjx/vWXb6uF1rwoQJXu/d2td4wIABZlVVlWf5p59+akoyX331Vc+yKVOm1Dm+0Lw4Ex1hYTYwr/OFF16o2NhY/fznP9eqVau8zkBujFtuucXvdfv166cLLrjAa9n48ePlcDj073//O6D9++uDDz7QiBEj6pzNdM899+jEiRN1vlk/9fJgSTr//PMlnbycqz7Hjx/X//t//0+33nqr11QhMTExuuuuu/Tdd9/5fZlaY/3tb39T//79deGFF6qqqsrzc/XVV3tdmldr+PDhXvOB2+12paSkePp3/Phxbd26VTfddJNiY2M967Vv397vs+xOFUg+d+3ape+//1533XWXLJaTv0rbt2+vW265RZ988km906Q0xGq1au3atfrqq6/07LPP6vbbb1dRUZGefPJJnXfeeX6/Rh988IFGjhypDh06KCYmRm3bttXjjz+uo0ePqrCw0LPegw8+qO3bt+tf//qXpJrL7V9++WVNmDDB55QyAFoHxmpvLX2srhXI+FZfX8vLyz3jzYcffihJuuOOO7zWGz9+fMjjb47XqbFq+3/6NC3/9V//pfPOO0///Oc/A952c8QPILowfntrCeP3I488orZt2youLk4XX3yxvv32W/3+97/Xtdde67Xe6a+Jv+PPpk2b5HA4NHnyZJ/T8XzzzTf6v//7P89Yfurn+muvvVaHDh3y9PHDDz/UiBEjPPPy1+bitttuCzgHvvr4j3/8Q1VVVbr77ru9YoqLi9PQoUPr1BqCsXnzZpWVldXJZ3p6uq666qqgxvPrrrtOMTExnseM55GJIjqa3fHjx3X06FF17drV5zq9evXS+++/r5SUFE2ZMkW9evVSr1699NxzzzVqX425tCU1NdXnsqNHjzZqv4119OjRemOtzdHp+09KSvJ6XDsXp6+pPySpuLhYpmk2aj+hcvjwYX3xxRdq27at14/NZpNpmnUuYz69f1JNH2v7V9uXUwflWvUta0gg+azNla98ut1uFRcXNzqWWuedd56ysrL0yiuv6Ntvv9WCBQt09OjROpdR1ufTTz/V6NGjJdXMg/uvf/1LW7Zs0ezZsyV59+vGG29Uz549PVPOrFy5UsePH2cqF6CVY6yuK5rH6u7du6uoqKjOfUjqE8j41lBfjx49qjZt2tRZr77XM1jN8Tp16dJFCQkJysvL8zsmyXdOg3nvBhI/gJaL8buuaB6/az344IPasmWLtm3bpr179+rQoUP6+c9/Xme90/fv7/hTVFQkSWe8Kezhw4clSTNmzKjzuX7y5MmS5Plcf/To0TO+5sE4vS+1cV1yySV14nrttdcanDKte/fujOfwW5twB4DW55133lF1dfUZb64oST/60Y/0ox/9SNXV1dq6dasWLVqkrKws2e123X777X7tqzE3TiooKPC5rPYXWlxcnCR5bl5RK5i5LGu3f+jQoTrLa29u2aVLl6C2L0mdOnWSxWJp8v3Up0uXLoqPj9eLL77o8/nG6NSpkwzD8AyYp6rvdWwKte8JX/m0WCzq1KlTSPZlGIYeeughPfHEE9qxY0eD669Zs0Zt27bV3/72N897VpLefPPNOutaLBZNmTJFjz76qJ555hktWbJEI0aMUJ8+fUISO4DoxFhdVzSP1VdffbXWrVunt99+u8HXpSnGt6SkJFVVVeno0aNeHxKbYsxujtcpJiZGI0aM0LvvvqvvvvvujEWH2pikmpyevu7333/vFVNcXFyd965U8/5tqr/TALQcjN91RfP4Xatbt24aNGhQg+ud/pr4O/7Uzq1e381Ga9WuO2vWrDo32qxV+xkyKSnpjK/5qaxWa73jnq+C9Ol9rI3rL3/5i3r06OEzfl+uvvpqLVq0SJ988kmD86I39DfS6eP56TdZl4J/PyO8OBMdzerbb7/VjBkz1KFDB02aNMmvNjExMRo8eLDnTNnay71C/c3czp079fnnn3stW716tWw2my666CJJ8txF+YsvvvBa76233qqzvVPPnG7IiBEj9MEHH3gG2FovvfSSEhIS/L7JxZm0a9dOgwcP1htvvOEVl9vt1iuvvKJu3bqpd+/eQe+nPtdff7327t2rpKQkDRo0qM5PY+9I3q5dOw0aNEhvvvmmKioqPMtLS0v1t7/9rc76jXkt/NWnTx+dddZZWr16tdclk8ePH9frr7+uIUOGKCEhodHbrW9AlmoGZYfD4XVWia9+GYahNm3aeF0OVlZWppdffrnebd9///2KjY3VHXfcoV27dumBBx5odNwAWg7G6vpF81g9ceJEpaamaubMmTp48GC969Te6Kwpxrfhw4dLkv7nf/7Ha/npN7b2JdJeJ6mmiGCapn72s595/S1Sq7KyUm+//bYk6aqrrpIkvfLKK17rbNmyRV9//bVGjBjhWdazZ886793du3cHNQ1AU/wdBCDyMH7XL5rH72D5O/5cdtll6tChg5YtW+ZzOqA+ffooMzNTn3/+eb2f6QcNGuSZknX48OH65z//6XXSW3V1tecGq6eqb9z74IMPVFpa6lcfr776arVp00Z79+71GdeZPPTQQ2rXrp0mT55cb9HbNE3PzV+HDBmi+Pj4Ovn87rvvPNMGndqv3bt3e31BcPToUW3atMmvftWHs9PDjzPR0WR27NjhmY+qsLBQ//u//6sVK1YoJiZGa9eurXMn6VMtW7ZMH3zwga677jp1795d5eXlnrOYR44cKUmy2Wzq0aOH/vrXv2rEiBHq3LmzunTp0uiCbK2uXbvqxz/+sbKzs5WWlqZXXnlF69ev1/z58z0fFi+55BL16dNHM2bMUFVVlTp16qS1a9fq448/rrO9AQMG6I033tDSpUt18cUXy2Kx+PwF/pvf/EZ/+9vfNHz4cD3++OPq3Lmz/ud//kfvvPOOcnNz1aFDh4D6dLp58+Zp1KhRGj58uGbMmKHY2FgtWbJEO3bs0KuvvtqoswlO9+WXX+ovf/lLneWXXHKJsrKy9Prrr+vKK6/UQw89pPPPP19ut1vffvut1q1bp+nTp2vw4MGN2t8TTzyh6667TldffbUefPBBVVdX6+mnn1b79u31ww8/eK07YMAAbdiwQW+//bbS0tJks9mCPtPaYrEoNzdXd9xxh66//npNmjRJLpdLTz/9tI4dO6annnoqoO3+/Oc/17Fjx3TLLbeof//+iomJ0f/93//p2WeflcVi0SOPPOLVr/reY9ddd50WLFig8ePH6+c//7mOHj2q//7v//YMuqfr2LGj7r77bi1dulQ9evQIaF55ANGJsbp1jNUdOnTQX//6V11//fUaOHCgHnjgAQ0ZMkSxsbHas2ePXnnlFX3++ecaO3Zsk4xvo0eP1pVXXqmZM2fq+PHjGjRokP71r3/5/HL3dJH4Og0ZMkRLly7V5MmTdfHFF+sXv/iF+vXrp8rKSn322Wd64YUX1L9/f91www3q06ePfv7zn2vRokWyWCwaM2aM9u/fr8cee0zp6el66KGHPNu96667dOedd2ry5Mm65ZZblJ+fr9zc3DMeiw1pTP4ARAfG78gbF5rys3ag/B1/2rdvr2eeeUb333+/Ro4cqZ/97Gey2+365ptv9Pnnn2vx4sWSpN///vcaM2aMrr76at1zzz0666yz9MMPP+jrr7/Wv//9b/35z3+WJP3617/WW2+9pauuukqPP/64EhIS9Pzzz9c7rdxdd92lxx57TI8//riGDh2qr776SosXL/b7denZs6eeeOIJzZ49W/v27dM111yjTp066fDhw/r000/Vrl07zZkzx2f7jIwMrVmzRrfddpsuvPBCPfDAAxo4cKAk6auvvtKLL74o0zR18803q2PHjnrsscf06KOP6u6779ZPf/pTHT16VHPmzFFcXJx+85vfePXr97//ve6880797Gc/09GjR5Wbm6vExET/Xrx6DBgwQJI0f/58jRkzRjExMTr//PO97hOHJhaOu5miZau9a3LtT2xsrJmSkmIOHTrUzMnJMQsLC+u0Of0u3ps3bzZvvvlms0ePHqbVajWTkpLMoUOHmm+99ZZXu/fff98cOHCgabVaTUmeuzfXbq+oqKjBfZlmzZ2fr7vuOvMvf/mL2a9fPzM2Ntbs2bOnuWDBgjrtd+/ebY4ePdpMTEw0k5OTzalTp5rvvPNOnTuG//DDD+att95qduzY0TQMw2ufqucO1F9++aV5ww03mB06dDBjY2PNCy64wFyxYoXXOrV3DP/zn//stbz2js6nr1+f//3f/zWvuuoqs127dmZ8fLx56aWXmm+//Xa922vMHcN9/dTGVFpaav761782+/TpY8bGxpodOnQwBwwYYD700ENed+2WZE6ZMqXOfk6/O7dpmubatWvNAQMGmLGxsWb37t3Np556yvzlL39pdurUyWu97du3m5dffrmZkJBgSvLcJdvX3e3ruwO8L2+++aY5ePBgMy4uzmzXrp05YsQI81//+le92zv9davPP/7xD/O+++4z+/bta3bo0MFs06aNmZaWZo4dO7bOHeHP9B578cUXzT59+phWq9U8++yzzXnz5pnLly+v9473pmmaGzZsMCWZTz31VIMxAoh+jNU1WstYXaugoMB85JFHzH79+pkJCQmm1Wo1zznnHHPSpEnml19+6bWuP+Obr9ew9v116nhz7Ngx87777jM7duxoJiQkmKNGjTL/7//+r06e62sbqa+Tadb8jTFhwgSze/fuZmxsrNmuXTtz4MCB5uOPP+51HFVXV5vz5883e/fubbZt29bs0qWLeeedd5oHDhzw2p7b7TZzc3PNs88+24yLizMHDRpkfvDBB+bQoUM9f780Nv4z5Q9AdGH8rhGp40JTfdZuaF1fn2tN0//xxzRN8+9//7s5dOhQs127dmZCQoLZt29fc/78+V7rfP755+a4cePMlJQUs23btmZqaqp51VVXmcuWLfNa71//+pd56aWXmlar1UxNTTUffvhh84UXXqgzxrtcLnPmzJlmenq6GR8fbw4dOtTcvn17nc//Z+qjadb83TJ8+HAzMTHRtFqtZo8ePcxbb73VfP/998+Yu1p79+41J0+ebJ5zzjmm1Wo14+Pjzb59+5rTpk2r8/n5j3/8o3n++ed76ho33nijuXPnzjrbXLVqlXneeeeZcXFxZt++fc3XXnvNnDBhgtmjRw/POmd6jU9/L7tcLvP+++83k5OTPe/7+j7bo+kYptnArZsBIEpUVlbqwgsv1FlnnaV169aFO5yoMn36dC1dulQHDhyo98auAAAAAAAArRXTuQCIWhMnTtSoUaOUlpamgoICLVu2TF9//XWj7yzfmn3yySfavXu3lixZokmTJlFABwAAAAAAOA1nogOIWuPGjdOmTZtUVFSktm3b6qKLLtKjjz6qa665JtyhRQ3DMJSQkKBrr71WK1asUPv27cMdEgAAAAAAQEShiA4AAAAAAAAAgA+WcAcAAAAAAAAAAECkoogOAAAAAAAAAIAPFNEBAAAAAAAAAPChTbgDaGput1vff/+9bDabDMMIdzgAANRhmqacTqe6du0qi4Xvt0/FOA4AiHSM474xjgMAIp2/43iLL6J///33Sk9PD3cYAAA06MCBA+rWrVu4w4gojOMAgGjBOF4X4zgAIFo0NI63+CK6zWaTVJOIxMTEMEcTGm63W0VFRUpOTm7VZzqQhxrkoQZ5qEEeakRbHhwOh9LT0z1jFk5qieO4v6LtfRwtyGvTIK9Ng7w2nVDmlnHct1CO4y3heIj2PkR7/FL09yHa45foQySI9vil8IzjLb6IXnvJWGJiYov58O12u1VeXq7ExMSofbOHAnmoQR5qkIca5KFGtOaBy5zraonjuL+i9X0c6chr0yCvTYO8Np2myC3jeF2hHMdbwvEQ7X2I9vil6O9DtMcv0YdIEO3xS+EZx6MzUwAAAAAAAAAANAOK6AAAAAAAAAAA+EARHQAAAAAAAAAAHyiiAwAAAAAAAADgA0V0AAAAAAAAAAB8oIgOAAAAAAAAAIAPFNEBAAAAAAAAAPAhrEX0qqoq/frXv1ZGRobi4+N19tln64knnpDb7fasY5qmsrOz1bVrV8XHx2vYsGHauXNnGKMGAAAAAAAAALQWYS2iz58/X8uWLdPixYv19ddfKzc3V08//bQWLVrkWSc3N1cLFizQ4sWLtWXLFqWmpmrUqFFyOp1hjBwAAAAAAAAA0BqEtYi+efNm3XjjjbruuuvUs2dP3XrrrRo9erS2bt0qqeYs9IULF2r27NkaO3as+vfvr1WrVunEiRNavXp1OEMHAAAAAAAAALQCYS2iX3HFFfrnP/+p3bt3S5I+//xzffzxx7r22mslSXl5eSooKNDo0aM9baxWq4YOHapNmzaFJWYAAAAAAAAAQOvRJpw7f+SRR1RSUqJzzz1XMTExqq6u1pNPPqmf/vSnkqSCggJJkt1u92pnt9uVn59f7zZdLpdcLpfnscPhkCS53W6vudajmdvtlmmaLaY/gQpnHo4cOeJ5b/krMTFRXbp0CXksvB9qkIca5KFGtOUhWuIE0HIUFRU1+m+ZMzFNU06nU6WlpTIMI+DtJCYmKjk5OWRxAWhZSkpKgv49E06h+F3J70kACI+wFtFfe+01vfLKK1q9erX69eun7du3KysrS127dtWECRM8650+uJim6XPAmTdvnubMmVNneVFRkcrLy0PbgTBxu90qKSmRaZqyWMJ6MUFYhSsPJSUleva5RTpe5mp45VO0i7fqoQenqkOHDiGNh/dDDfJQgzzUiLY8cJ8PAM2pqKhIv7h3glzOYyHbpmEYSs84Wwfy9sk0zYC3Y7V11NIVqygQAajjyJEj+t2S3+nrvV8H9XsmnAzDUM/0ntp/YH/AfWgf114vLnuR35MA0MzCWkR/+OGH9atf/Uq33367JGnAgAHKz8/XvHnzNGHCBKWmpkqqOSM9LS3N066wsLDO2em1Zs2apWnTpnkeOxwOpaenKzk5WYmJiU3Ym+bjdrtlGIaSk5OjojjUVMKVh9LSUn3x1W5lXjlWiUn1vw9P5zh6WF989IZiYmKUkpIS0nh4P9QgDzXIQ41oy0NcXFy4QwDQijgcDrmcxzT9R+cpPSk0X+6bkpwJSbKdn6xAzw89cLREz/zv13I4HBSHANThcDhUVlGmEZNHqEu30F/h2yxMyVpm1eD4wQrkl+WRg0f0/uL3+T0JAGEQ1iL6iRMn6hQ3YmJiPJe1Z2RkKDU1VevXr9fAgQMlSRUVFdq4caPmz59f7zatVqusVmud5RaLJSoKKf4yDKPF9SkQ4ciDYRgyTVO2JLs62bv51cbUySsomiJW3g81yEMN8lAjmvIQDTG2FKGewiKUuDwbzS09qYN62ZNCsi23pMIYm1JsYb7pEoAWr8tZXZR2dlrDK0Yg021KRyUlSYYlOqekAYDWKqxF9BtuuEFPPvmkunfvrn79+umzzz7TggULdN9990mqKYBkZWUpJydHmZmZyszMVE5OjhISEjR+/Phwhg4AAKJMU0xhEUpMYwEAAAAAkSmsRfRFixbpscce0+TJk1VYWKiuXbtq0qRJevzxxz3rzJw5U2VlZZo8ebKKi4s1ePBgrVu3TjabLYyRAwCAaNMUU1iESqRPY8EZ/EB4jgN/b0LIcQAAANC0wlpEt9lsWrhwoRYuXOhzHcMwlJ2drezs7GaLCwAAtFyhnMKiNeAMfiB8x4G/N2zlOAAAAGhaYS2iAwAAILJxBj8QvuPAnxu2chwAAAA0PYroAAAAaBBn8APNfxxww1YAAIDIwN9iAAAAAAAAAAD4QBEdAAAAAAAAAAAfmM4FAAAAUctVUan8/Hy/1zdNU06nU6WlpTIMX7NMh0ZiYiJzVKPVKyoqksPhCHcYdZimqerqaqWkpIQ7FAAAEAUoogMAACAqHS09oX15eXpq9sOyxlr9amMYhtIzztaBvH0yTbNJ47PaOmrpilUU0tFqFRUV6Rf3TpDLeSzcodRhGIbO7nu+Hs2eQyEdAAA0iCI6AAAAolJpeYViLdJDl5+r3mfZ/WpjSnImJMl2frKa8jz0A0dL9Mz/fi2Hw0ERHa2Ww+GQy3lM0390ntKTOoQ7HC/fHnXoLwdL5XA4KKIDAIAGUUQHAABAVOvWKVG97El+reuWVBhjU4qNmwMBzSU9qYPfx2hzMSXpYFG4wwAAAFGCzw4AAAAAAAAAAPhAER0AAAAAAAAAAB8oogMAAAAAAAAA4ANFdAAAUMdHH32kG264QV27dpVhGHrzzTe9njdNU9nZ2eratavi4+M1bNgw7dy502sdl8ulqVOnqkuXLmrXrp1+/OMf67vvvmvGXgAAAAAAEDxuLAoAAOo4fvy4LrjgAt1777265ZZb6jyfm5urBQsWaOXKlerdu7fmzp2rUaNGadeuXbLZbJKkrKwsvf3221qzZo2SkpI0ffp0XX/99dq2bZtiYmKau0tAs3NVVCo/Pz/cYdSRn5+vqqqqcIcBAAAARA2K6AAAoI4xY8ZozJgx9T5nmqYWLlyo2bNna+zYsZKkVatWyW63a/Xq1Zo0aZJKSkq0fPlyvfzyyxo5cqQk6ZVXXlF6erref/99XX311c3WFyAcjpae0L68PD01+2FZY63hDsfL8bJyHf7+oFyVg8MdCoAmsnTpUi1dulT79++XJPXr10+PP/64Z2w3TVNz5szRCy+8oOLiYg0ePFjPP/+8+vXr59mGy+XSjBkz9Oqrr6qsrEwjRozQkiVL1K1bt3B0CQCAsKKIDgAAGiUvL08FBQUaPXq0Z5nVatXQoUO1adMmTZo0Sdu2bVNlZaXXOl27dlX//v21adMmiuho8UrLKxRrkR66/Fz1Psse7nC8fPLNAT259ltVV3I2OtBSdevWTU899ZTOOeccSTVfdt9444367LPP1K9fP64oAwCgkSiiAwCARikoKJAk2e3ehUG73e6ZuqKgoECxsbHq1KlTnXVq29fH5XLJ5XJ5HjscDkmS2+2W2+0OKm7TNGUYhkxJwW0p9EypJjbT9PTT7XZ7PQ5bbBGdN0MWi0WmDL9jc0vN0pfa2M7q1EEZ9qQm3lvj7D9S0ui8NSQUea3vOIgU4ToO/MkreQtMKPMWaXmXpBtuuMHr8ZNPPqmlS5fqk08+Ud++fbmiDACARqKIDgAAAmIYhtfj2mLJmTS0zrx58zRnzpw6y4uKilReXh5YoP/hdDqVnnG2nAlJKoyxBbWtUHMmqCY2p1OFhYWSaooyJSUlMk1TFkv47gUfyXmr7pii3v0G6ISti9+xuSWVWOJlSmrKrAYSW3NpithCkdf6joNIEa7jwJ+8krfAlCZIXextVFpaGnTenE5niKJqGtXV1frzn/+s48ePa8iQIU16RVlzfBkuUzLdZlDbCptTvhkzFUAfzPB+aRYpX/AHI9r7EO3xS/QhEkR7/FJo++DvNiiiAwCARklNTZVUc7Z5WlqaZ3lhYaHn7PTU1FRVVFSouLjY62z0wsJCXXbZZT63PWvWLE2bNs3z2OFwKD09XcnJyUpMTAwq7tLSUh3I2yfb+clKiaxajkpPHK2JzWZTSkqKpJo/5gzDUHJycliL6JGct5hjhdq980slXNlHKV38m3fcLcmQlFztbNIieiCxNZemiC0Uea3vOIgU4ToO/MkreQuM88RRHTl8RO3btw86b3FxcSGKKrS+/PJLDRkyROXl5Wrfvr3Wrl2rvn37atOmTZKa5oqypvwyvLS0VCldUmQts0pHg9pU+JiSar9zOfN5B/WyllnVM71n2L40i5Qv+IMR7X2I9vgl+hAJoj1+KbR98PfLcIroAACgUTIyMpSamqr169dr4MCBkqSKigpt3LhR8+fPlyRdfPHFatu2rdavX69x48ZJkg4dOqQdO3YoNzfX57atVqus1rpFPYvFEvQfR7Vnbhlq2jOQA2Ho5Bl2p/az9nE4/7iN7LzVnH1iyGxUbLV9acr+BBpbc2iq2ILNq6/jIBKE8zhoKK/kLTChzFuk5b1Wnz59tH37dh07dkyvv/66JkyYoI0bN3qeb4orypryy3Cn06nCI4XqGd9TiqxZsvxXe7JjZwV0ULgcLu0/sD9sX5pFyhf8wYj2PkR7/BJ9iATRHr8U2j74+2U4RXQAAFBHaWmpvvnmG8/jvLw8bd++XZ07d1b37t2VlZWlnJwcZWZmKjMzUzk5OUpISND48eMlSR06dNDEiRM1ffp0JSUlqXPnzpoxY4YGDBjgmVsVAAA0ndjYWM+NRQcNGqQtW7boueee0yOPPCKpaa4oa44vw2VIhiWA07gjgCnT881YQH0wwv+lWSR8wR+saO9DtMcv0YdIEO3xS6Hrg7/tozdTAACgyWzdulUDBw70nGk+bdo0DRw4UI8//rgkaebMmcrKytLkyZM1aNAgHTx4UOvWrZPNdvJ6/WeffVY33XSTxo0bp8svv1wJCQl6++23FRMTE5Y+AQDQmpmmKZfL5XVFWa3aK8pqC+SnXlFWq/aKsjMV0QEAaKk4Ex0AANQxbNiwmrO9fDAMQ9nZ2crOzva5TlxcnBYtWqRFixY1QYQAAMCXRx99VGPGjFF6erqcTqfWrFmjDRs26L333pNhGFxRBgBAI1FEBwAAAACgBTl8+LDuuusuHTp0SB06dND555+v9957T6NGjZJUc0VZWVmZJk+erOLiYg0ePLjeK8ratGmjcePGqaysTCNGjNDKlSu5ogwA0CpRRAcAAAAAoAVZvnz5GZ/nijIAABqHOdEBAAAAAAAAAPCBIjoAAAAAAAAAAD6EdTqXnj17Kj8/v87yyZMn6/nnn5dpmpozZ45eeOEFzzxtzz//vPr16xeGaGsUFRXJ4XA0qk1iYqKSk5ObKCLAG+9RAAAAAAAAIHTCWkTfsmWLqqurPY937NihUaNG6Sc/+YkkKTc3VwsWLNDKlSvVu3dvzZ07V6NGjdKuXbu8bnjSXIqKinT3vffrmPNEo9p1tCXopRV/pEiJJsd7FAAAAAAAAAitsBbRTy/YPfXUU+rVq5eGDh0q0zS1cOFCzZ49W2PHjpUkrVq1Sna7XatXr9akSZOaPV6Hw6FjzhPKHHqLEpPs/rU5elh7Nr4uh8NBgRJNjvcoAAAAAAAAEFphLaKfqqKiQq+88oqmTZsmwzC0b98+FRQUaPTo0Z51rFarhg4dqk2bNvksortcLrlcLs/j2mkt3G633G53UDGapinDMNQhya5O9rP8amOo5s7npmkGvf9abrc7pNuLVuHKQ+37wKh55Febpngf1Do1D5HyHg0Hjosa5KFGtOUhWuIEAAAAAKA1ipgi+ptvvqljx47pnnvukSQVFBRIkux277Np7XZ7vfOo15o3b57mzJlTZ3lRUZHKy8uDitHpdKpXRg8lxUs2i6vhBpIs8VKvjB5yOp0qLCwMav+13G63SkpKZJqmLJbWe2/YcOUhUt4HtU7NQ6TF1pw4LmqQhxrRlgen0xnuEAAAAAAAgA8RU0Rfvny5xowZo65du3otNwzD63Htmba+zJo1S9OmTfM8djgcSk9PV3JyshITE4OKsbS0VHvz8tXxQsmdaPWrTXGZtDcvXzabTSkpKUHtv5bb7ZZhGEpOTo6K4lBTCVceIuV9UOvUPJw4cSKiYmtOHBc1yEONaMtDXFxcuENABHBVVHqdKFD75WhpaekZ//Zpavn5+aqqqgrb/gEAAAAg3CKiiJ6fn6/3339fb7zxhmdZamqqpJoz0tPS0jzLCwsL65ydfiqr1SqrtW7x0GKxBF1IqZ3yomYCD/8+zJo6WfgPZSGndnvRUBxqSuHIQyS9D06NyWKxRGRszYnjogZ5qBFNeYiGGNG0jpae0L68PD01+2FZY2v+jjEMQ+kZZ+tA3j6Zpn/ThzWF42XlOvz9QbkqB4ctBgAAAAAIp4gooq9YsUIpKSm67rrrPMsyMjKUmpqq9evXa+DAgZJq5k3fuHGj5s+fH65QAQAAQq60vEKxFumhy89V77NqThYwJTkTkmQ7P9nPr0WbxiffHNCTa79VdSVnowMAAABoncJeRHe73VqxYoUmTJigNm1OhmMYhrKyspSTk6PMzExlZmYqJydHCQkJGj9+fBgjBgAAaBrdOiWqlz1JkuSWVBhjU4pNCue1CvlHjoVx7wAAAAAQfmEvor///vv69ttvdd9999V5bubMmSorK9PkyZNVXFyswYMHa926dbLZbGGIFAAAAAAAAADQ2oS9iD569Gif83wahqHs7GxlZ2c3b1AAAAAAAAAAACgCiuhAuBUVFcnhcPi9fn5+vqqqmRcWAAAAAAAAaA0ooqNVKyoq0t333q9jzhN+tykvO6GDhw7poorKJowMAAAAAAAAQCSgiI5WzeFw6JjzhDKH3qLEJLtfbQ7u2aH8N15UVRVFdAAAAAAAAKClo4gOSEpMsquzvZtf65YcKWjiaAAAAAAAAABECku4AwAAAAAAAAAAIFJRRAcAAAAAAAAAwAeK6AAAAAAAAAAA+EARHQAAAAAAAAAAHyiiAwAAAAAAAADgA0V0AAAAAAAAAAB8oIgOAAAAAAAAAIAPbcIdAOBLUVGRHA6Hz+dN05TT6VRpaakMw5AkJSYmKjk5ublCBAAAAAAAANDCUURHRCoqKtLd996vY84TPtcxDEO9Mnpob16+TNOUJHW0JeilFX+kkA4AAAAAAAAgJCiiIyI5HA4dc55Q5tBblJhkr3cdQ1JSvNTxQsmU5Dh6WHs2vi6Hw0ERHQAAAAAAAEBIUERHREtMsquzvZuPZ03ZLC65E62qKakDAAAAAAAAQGhRRAcAAAAQEVwVlcrPzw93GHXk5+erqqoq3GEAAAAgTCiiAwAAAAi7o6UntC8vT0/NfljWWGu4w/FyvKxch78/KFfl4HCHAgAAgDCgiA4AAAAg7ErLKxRrkR66/Fz1Pqv+e+KEyyffHNCTa79VdSVnowMAALRGFNEBAAAARIxunRLVy54U7jC85B85Fu4QAAAAEEaWcAcAAAAAAAAAAECkoogOAAAAAEALMm/ePF1yySWy2WxKSUnRTTfdpF27dnmtc88998gwDK+fSy+91Gsdl8ulqVOnqkuXLmrXrp1+/OMf67vvvmvOrgAAEBGYzgUAAAAAopirolL5+fnhDqOO/Px8VVUxj3w4bNy4UVOmTNEll1yiqqoqzZ49W6NHj9ZXX32ldu3aeda75pprtGLFCs/j2NhYr+1kZWXp7bff1po1a5SUlKTp06fr+uuv17Zt2xQTE9Ns/QEAINwoogMAAABAlDpaekL78vL01OyHZY21hjscL8fLynX4+4NyVQ4Odyitznvvvef1eMWKFUpJSdG2bdt05ZVXepZbrValpqbWu42SkhItX75cL7/8skaOHClJeuWVV5Senq73339fV199ddN1AACACEMRHQAAAACiVGl5hWIt0kOXn6veZ9nDHY6XT745oCfXfqvqSs5GD7eSkhJJUufOnb2Wb9iwQSkpKerYsaOGDh2qJ598UikpKZKkbdu2qbKyUqNHj/as37VrV/Xv31+bNm2iiA4AaFUoogMAAABAlOvWKVG97EnhDsNL/pFj4Q4BkkzT1LRp03TFFVeof//+nuVjxozRT37yE/Xo0UN5eXl67LHHdNVVV2nbtm2yWq0qKChQbGysOnXq5LU9u92ugoKCevflcrnkcrk8jx0OhyTJ7XbL7XYH3Q/DMCRTMt1mUNsKG7cks+ZfUwH0wZQMw5BpmkHnMxButzts+w6VaO9DtMcv0YdIEO3xS6Htg7/bCHsR/eDBg3rkkUf07rvvqqysTL1799by5ct18cUXS6oZKOfMmaMXXnhBxcXFGjx4sJ5//nn169cvzJEDAAAAABDZHnjgAX3xxRf6+OOPvZbfdtttnv/3799fgwYNUo8ePfTOO+9o7NixPrfnKWbXY968eZozZ06d5UVFRSovLw+wBzVKS0uV0iVF1jKrdDSoTYWPKcn5n//Xn8IzspZZ1TO9p5xOpwoLC0MZmV/cbrdKSkpkmqYsFkuz7z8Uor0P0R6/RB8iQbTHL4W2D06ns+GVFOYienFxsS6//HINHz5c7777rlJSUrR371517NjRs05ubq4WLFiglStXqnfv3po7d65GjRqlXbt2yWazhS94AAAAAAAi2NSpU/XWW2/po48+Urdu3c64blpamnr06KE9e/ZIklJTU1VRUaHi4mKvs9ELCwt12WWX1buNWbNmadq0aZ7HDodD6enpSk5OVmJiYlB9cTqdKjxSqJ7xPaXIuujCf7UnO3aWFEDNx+Vwaf+B/bLZbJ5pd5qT2+2WYRhKTk6O6sJbNPch2uOX6EMkiPb4pdD2IS4uzq/1wlpEnz9/vtLT073uBt6zZ0/P/03T1MKFCzV79mzPN+GrVq2S3W7X6tWrNWnSpOYOGQAAAACAiGaapqZOnaq1a9dqw4YNysjIaLDN0aNHdeDAAaWlpUmSLr74YrVt21br16/XuHHjJEmHDh3Sjh07lJubW+82rFarrNa6N7i1WCxBFzlqpzGRIRmWAE7jjgCmauKXJcA+GCevBAhX4at239FaeJOivw/RHr9EHyJBtMcvha4P/rYPa6beeustDRo0SD/5yU+UkpKigQMH6g9/+IPn+by8PBUUFHjdyMRqtWro0KHatGlTOEIGAAD/UVVVpV//+tfKyMhQfHy8zj77bD3xxBNec8qZpqns7Gx17dpV8fHxGjZsmHbu3BnGqAEAaPmmTJmiV155RatXr5bNZlNBQYEKCgpUVlYmqWZqlBkzZmjz5s3av3+/NmzYoBtuuEFdunTRzTffLEnq0KGDJk6cqOnTp+uf//ynPvvsM915550aMGCARo4cGc7uAQDQ7MJ6Jvq+ffu0dOlSTZs2TY8++qg+/fRT/fKXv5TVatXdd9/tuVmJ3e59l3m73a78/Px6t9kcNzKp+b7Yv5uAGAr9jT9awg0AGuJfrs1TfgLLdaCvqcViaZb3wZEjRzzvYV9M05TT6ZTT6dS3336rand12N+j4dAajgt/kIca0ZaHaInzdPPnz9eyZcu0atUq9evXT1u3btW9996rDh066MEHH5TEtGwAAITD0qVLJUnDhg3zWr5ixQrdc889iomJ0ZdffqmXXnpJx44dU1pamoYPH67XXnvNa3x+9tln1aZNG40bN05lZWUaMWKEVq5cqZiYmObsDgAAYRfWIrrb7dagQYOUk5MjSRo4cKB27typpUuX6u677/asd/pNS8J1IxOn06leGT2UFC/ZLK6GG0iyxEu9MnqE9MYfLeEGAA3xL9emEiyV//m/EVCuA3lNUztYNaDfeUppH6MOTfg+KCkp0bPPLdLxsgb2YRhKsyfr0OEiVbjKldi+nTq2rW7S2CJRazgu/EEeakRbHvy9kUmk2bx5s2688UZdd911kmqmZHv11Ve1detWSUzLBgBAuJjmmU+oiY+P1z/+8Y8GtxMXF6dFixZp0aJFoQoNAICoFNYielpamvr27eu17LzzztPrr78uqeZGJpJUUFDgmZdNqrmRyelnp9dqyhuZlJaWam9evjpeKLkT687zVp/iMmlvXn5Ib/zREm4A0BD/cl3zh2GJ2yrJCCjXgbymBSUufbnza/UaVq02yU33PigtLdUXX+1W5pVjlZhU//tdqjmTPCFe6pQqfffNDm3fuFI9r3TJ6By+92g4tIbjwh/koUa05cHfG5lEmiuuuELLli3T7t271bt3b33++ef6+OOPtXDhQkkNT8tGER0AAAAAEA3CWkS//PLLtWvXLq9lu3fvVo8ePSRJGRkZSk1N1fr16zVw4EBJUkVFhTZu3Kj58+fXu83muJHJyclDGmaqaW780RJuAHAm/ufa8PwEkutAX1O3293k74Pa2GxJdnWydzvj1m0Wl9yJVhUfKWiW2CJVSz8u/EUeakRTHqIhxvo88sgjKikp0bnnnquYmBhVV1frySef1E9/+lNJithp2UxJkTaBjqma96spwxObW4qIWOuLLVIEEltz5bWl5a0hochra8uZP/zJK3kLjKnQTWkYrdOyAQAA/4W1iP7QQw/psssuU05OjsaNG6dPP/1UL7zwgl544QVJNX/UZGVlKScnR5mZmcrMzFROTo4SEhI0fvz4cIYONFplRYXPolF98vPzVVVd1YQRAUBwXnvtNc9Ny/r166ft27crKytLXbt21YQJEzzrRdK0bOkZZ8uZkKTCmMiaj726Y4p69xugE7Yuntjckkos8TIV3jvB1xdbpAgktubKa0vLW0NCkdfWljN/+JNX8haY0gSpi72NSktLg57SMFqnZQMAAP4LaxH9kksu0dq1azVr1iw98cQTysjI0MKFC3XHHXd41pk5c6bKyso0efJkFRcXa/DgwVq3bh03I0NUKSstUV7ePj08O1uxsf5Ns1JedkIH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sWPYvn07CgsLER0djZMnT9YZf13jkJmZiffffx/Hjh1DSUkJEhIS8NtvvzGfk1ZwEp1IhfT0dPj6+iot77zzDhwdHXHkyBFUVFQgJCQE3bp1Q0REBJo3b17nF8BqJiYm2L17N/7zn/+gR48emDp1Kt5++20A/7vHt6mpKVatWoWPP/4Yjo6OGD58eIPbMmLECHz00UdYvnw5PD098fHHH2PLli3o169fvY4TEhKCbdu2YdCgQfDw8MCMGTMQEhKCr7/+WrFNTEwMLl68iA4dOihuZbJw4UI88cQTCAkJQb9+/WBvb6/ycnFbW1uMHj0aNjY2D72cvNrLL7+M/fv3K/3AEBUVhdDQUEyaNAkBAQFo1qyZ0gR4kyZNsGPHDuTk5KBbt2544403sHz5cpXHHz9+PExNTfHiiy8q3Xu9RYsWSEhIwIABA+Dh4YENGzbgq6++gqenJwDg/v37SExMxMsvv1xnG4iISPfee++9WpcLe3h4YN26dVi7di28vb2RlZVV7x+XHyY2NhZxcXHw9vbGoUOH8O233yquTtJGLVFt5syZiIqKQlRUFLy8vJCUlIQ9e/agU6dO9TpO+/btcfz4cfTv3x9RUVHo1q0bBg4ciNTUVKxfvx5A1Q/9u3fvRsuWLdGnTx8EBwfDzc0NO3furNdnLV++HH369MGwYcMQHByMwMBA+Pn5PXSf+Ph4pKSkwNnZGb6+viq30UZfVFRUIDw8HB4eHhg8eDDc3d2xbt26erWPiIi0g/lbNzTJ5/369cO//vUv7NmzBz4+PhgwYAAyMzMV72/ZsgWhoaGIioqCu7s7hg0bhszMTMXV5D179sQnn3yC1atXw8fHB8nJyYo5j2ohISFYuHAh5s6dix49euDu3bsIDQ2tM/66xsHW1hYHDx7EkCFD0LlzZ7z99tuIj4/HM888o6UepMZMJnR9c0IiqtORI0cQGBiI8+fPo0OHDlKHI5mBAwfCw8MDq1at0mj7sWPHwtfXF2+++abWY7l8+TJcXV2RnZ2NJ554QuP91q5di2+//RbJyclaj4mIiIiIiIiIiPSPDxYlkkBiYiJsbGzQqVMnnD9/HhEREejVq1ejnUC/efMmkpOTceDAAaxZs0bj/ZYvX449e/ZoNZaysjLI5XLMnz8fPXv2rNcEOlB139aalx0SEREREREREZHx4pnoRBLYvn073nvvPVy+fBmtW7dGcHAw4uPjYWdnJ3VoknB1dcWtW7ewcOFCrV6O1xDp6eno378/OnfujG+++QZeXl6SxkNERERERERERNLiJDoRERERERERERERkRp8sCgRERERERERERERkRqcRCciIiIiIiIiIiIiUoOT6EREREREREREREREanASnYiIiIiIiIiIiIhIDU6iExERERERERERERGpwUl0IiIiIiIiIiIiIiI1OIlORERERERERERERKQGJ9GJiIiIiIiIiIiIiNTgJDoRERERERERERERkRr/D1D95YIoLOP/AAAAAElFTkSuQmCC" + }, + "metadata": {}, + "output_type": "display_data", + "jetTransient": { + "display_id": null + } + } + ], + "execution_count": 4 + }, + { + "metadata": {}, + "cell_type": "code", + "outputs": [], + "execution_count": null, + "source": [ + "# ==============================================================================\n", + "# 5. SPLITING THE DATA INTO TRAIN/VAL/TEST\n", + "# ==============================================================================" + ] + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2025-11-24T21:37:29.272003Z", + "start_time": "2025-11-24T21:37:29.266541Z" + } + }, + "cell_type": "code", + "source": [ + "# Split dataset into train/val/test\n", + "train_dataset, val_dataset, test_dataset = split_by_patient(\n", + " dataset,\n", + " ratios=[0.7, 0.15, 0.15],\n", + " seed=42\n", + ")\n", + "\n", + "print(f\"\\nDataset Split:\")\n", + "print(f\" Training samples: {len(train_dataset)}\")\n", + "print(f\" Validation samples: {len(val_dataset)}\")\n", + "print(f\" Test samples: {len(test_dataset)}\")" + ], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Dataset Split:\n", + " Training samples: 700\n", + " Validation samples: 150\n", + " Test samples: 150\n" + ] + } + ], + "execution_count": 7 + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2025-11-24T21:37:13.930178Z", + "start_time": "2025-11-24T21:37:13.927354Z" + } + }, + "cell_type": "code", + "source": [ + "# ==============================================================================\n", + "# 6. LOADING THE DATA INTO DATALOADERS\n", + "# ==============================================================================" + ], + "outputs": [], + "execution_count": 5 + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2025-11-24T21:37:32.670213Z", + "start_time": "2025-11-24T21:37:32.665511Z" + } + }, + "cell_type": "code", + "source": [ + "# Create dataloaders\n", + "train_loader = get_dataloader(train_dataset, batch_size=32, shuffle=True)\n", + "val_loader = get_dataloader(val_dataset, batch_size=32, shuffle=False)\n", + "test_loader = get_dataloader(test_dataset, batch_size=32, shuffle=False)\n", + "\n", + "print(f\"\\nDataLoader Batches:\")\n", + "print(f\" Training: {len(train_loader)} batches\")\n", + "print(f\" Validation: {len(val_loader)} batches\")\n", + "print(f\" Test: {len(test_loader)} batches\")\n" + ], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "DataLoader Batches:\n", + " Training: 22 batches\n", + " Validation: 5 batches\n", + " Test: 5 batches\n" + ] + } + ], + "execution_count": 8 + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2025-11-24T19:56:14.711331Z", + "start_time": "2025-11-24T19:56:14.707838Z" + } + }, + "cell_type": "code", + "source": [ + "# ==============================================================================\n", + "# 7. MODEL INITIALIZATION\n", + "# ==============================================================================" + ], + "outputs": [], + "execution_count": 78 + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2025-11-24T21:38:23.643266Z", + "start_time": "2025-11-24T21:38:23.602620Z" + } + }, + "cell_type": "code", + "source": [ + "# Initialize TPC model with hyperparameters from the paper\n", + "model = TPC(\n", + " dataset=dataset,\n", + " embedding_dim=128, # Embedding dimension for medical codes\n", + " num_layers=3, # Number of TPC blocks (default from paper)\n", + " num_filters=8, # Filters per temporal convolution (paper default)\n", + " pointwise_channels=128, # Channels for pointwise convolutions (paper default)\n", + " kernel_size=4, # Temporal convolution kernel size (paper default)\n", + " dropout=0.3, # Dropout rate (paper default)\n", + ")\n", + "\n", + "# Model statistics\n", + "total_params = sum(p.numel() for p in model.parameters())\n", + "trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)\n", + "\n", + "print(\"\\n TPC Model Initialized\")\n", + "print(\"-\" * 40)\n", + "print(f\"Total parameters: {total_params:,}\")\n", + "print(f\"Trainable parameters: {trainable_params:,}\")\n", + "print(f\"Model size: ~{total_params * 4 / 1024 / 1024:.2f} MB\")\n", + "\n", + "print(\"\\nModel Architecture:\")\n", + "print(f\" Input features: {model.feature_keys}\")\n", + "print(f\" Embedding dimension: {model.embedding_dim}\")\n", + "print(f\" Number of TPC blocks: {model.num_layers}\")\n", + "print(f\" Task mode: {model.mode}\")\n", + "\n", + "# Print detailed architecture\n", + "print(\"\\nTPC Block Configuration:\")\n", + "for i, block in enumerate(model.tpc_blocks, 1):\n", + " print(f\" Layer {i}:\")\n", + " print(f\" - Input channels: {block.input_channels}\")\n", + " print(f\" - Output channels: {block.output_channels}\")\n", + " print(f\" - Dilation: {block.dilation}\")" + ], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + " TPC Model Initialized\n", + "----------------------------------------\n", + "Total parameters: 4,764,545\n", + "Trainable parameters: 4,764,545\n", + "Model size: ~18.18 MB\n", + "\n", + "Model Architecture:\n", + " Input features: ['conditions', 'procedures']\n", + " Embedding dimension: 128\n", + " Number of TPC blocks: 3\n", + " Task mode: regression\n", + "\n", + "TPC Block Configuration:\n", + " Layer 1:\n", + " - Input channels: 256\n", + " - Output channels: 2432\n", + " - Dilation: 1\n", + " Layer 2:\n", + " - Input channels: 2432\n", + " - Output channels: 22016\n", + " - Dilation: 4\n", + " Layer 3:\n", + " - Input channels: 22016\n", + " - Output channels: 198272\n", + " - Dilation: 7\n" + ] + } + ], + "execution_count": 10 + }, + { + "metadata": {}, + "cell_type": "code", + "outputs": [], + "execution_count": null, + "source": [ + "# ==============================================================================\n", + "# 8. MODEL TRAINING\n", + "# ==============================================================================" + ] + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2025-11-24T21:49:49.442627Z", + "start_time": "2025-11-24T21:39:12.536792Z" + } + }, + "cell_type": "code", + "source": [ + "# Set device\n", + "device = \"cuda\" if torch.cuda.is_available() else \"cpu\"\n", + "print(f\"\\nTraining on device: {device}\")\n", + "\n", + "# Initialize trainer\n", + "trainer = Trainer(\n", + " model=model,\n", + " device=device,\n", + " metrics=[\"mae\", \"mse\"], # Track MAE and MSE during training\n", + " enable_logging=True, # Enable training logs\n", + " output_path=\"./outputs/tpc_los\" # Save model checkpoints here\n", + ")\n", + "\n", + "print(\"\\n Trainer initialized\")\n", + "print(f\" Metrics: {trainer.metrics}\")\n", + "\n", + "# Train the model\n", + "print(\"\\nStarting training...\")\n", + "print(\"-\" * 40)\n", + "\n", + "trainer.train(\n", + " train_dataloader=train_loader,\n", + " val_dataloader=val_loader,\n", + " epochs=5, # Number of training epochs\n", + " monitor=\"mae\", # Monitor MAE for early stopping\n", + " monitor_criterion=\"min\", # Lower MAE is better\n", + " patience=5, # Stop if no improvement for 5 epochs\n", + " load_best_model_at_last=True # Load best model after training\n", + ")\n", + "\n", + "print(\"\\n Training completed!\")" + ], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Training on device: cpu\n", + "TPC(\n", + " (embedding_model): EmbeddingModel(embedding_layers=ModuleDict(\n", + " (conditions): Embedding(101, 128, padding_idx=0)\n", + " (procedures): Embedding(52, 128, padding_idx=0)\n", + " ))\n", + " (tpc_blocks): ModuleList(\n", + " (0): TPCBlock(\n", + " (temporal_conv): Conv1d(256, 2048, kernel_size=(4,), stride=(1,), groups=256)\n", + " (bn_temporal): BatchNorm1d(2048, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)\n", + " (pointwise_conv): Linear(in_features=256, out_features=128, bias=True)\n", + " (bn_pointwise): BatchNorm1d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)\n", + " (dropout_temporal): Dropout(p=0.3, inplace=False)\n", + " (dropout_pointwise): Dropout(p=0.3, inplace=False)\n", + " )\n", + " (1): TPCBlock(\n", + " (temporal_conv): Conv1d(2432, 19456, kernel_size=(4,), stride=(1,), dilation=(4,), groups=2432)\n", + " (bn_temporal): BatchNorm1d(19456, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)\n", + " (pointwise_conv): Linear(in_features=2432, out_features=128, bias=True)\n", + " (bn_pointwise): BatchNorm1d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)\n", + " (dropout_temporal): Dropout(p=0.3, inplace=False)\n", + " (dropout_pointwise): Dropout(p=0.3, inplace=False)\n", + " )\n", + " (2): TPCBlock(\n", + " (temporal_conv): Conv1d(22016, 176128, kernel_size=(4,), stride=(1,), dilation=(7,), groups=22016)\n", + " (bn_temporal): BatchNorm1d(176128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)\n", + " (pointwise_conv): Linear(in_features=22016, out_features=128, bias=True)\n", + " (bn_pointwise): BatchNorm1d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)\n", + " (dropout_temporal): Dropout(p=0.3, inplace=False)\n", + " (dropout_pointwise): Dropout(p=0.3, inplace=False)\n", + " )\n", + " )\n", + " (fc): Linear(in_features=198272, out_features=1, bias=True)\n", + ")\n", + "Metrics: ['mae', 'mse']\n", + "Device: cpu\n", + "\n", + "\n", + " Trainer initialized\n", + " Metrics: ['mae', 'mse']\n", + "\n", + "Starting training...\n", + "----------------------------------------\n", + "Training:\n", + "Batch size: 32\n", + "Optimizer: \n", + "Optimizer params: {'lr': 0.001}\n", + "Weight decay: 0.0\n", + "Max grad norm: None\n", + "Val dataloader: \n", + "Monitor: mae\n", + "Monitor criterion: min\n", + "Epochs: 5\n", + "Patience: 5\n", + "\n" + ] + }, + { + "data": { + "text/plain": [ + "Epoch 0 / 5: 0%| | 0/22 [00:00>> new_patients = [\n", + " ... {'conditions': [1, 5, 12], 'procedures': [2, 7]},\n", + " ... {'conditions': [3, 8], 'procedures': [1, 4, 6]},\n", + " ... ]\n", + " >>> predictions = predict_new_patients(model, new_patients)\n", + " >>> print(predictions) # [3.45, 5.67]\n", + " \"\"\"\n", + " # Prepare samples with dummy labels\n", + " inference_samples = []\n", + " for i, patient in enumerate(patients_data):\n", + " sample = {\n", + " 'patient_id': patient.get('patient_id', f'inference-patient-{i}'),\n", + " 'visit_id': patient.get('visit_id', f'inference-visit-{i}'),\n", + " 'conditions': patient['conditions'],\n", + " 'procedures': patient['procedures'],\n", + " 'label': 0.0, # Dummy label (required by SampleDataset but ignored)\n", + " }\n", + " inference_samples.append(sample)\n", + "\n", + " # Create inference dataset\n", + " inference_dataset = SampleDataset(\n", + " samples=inference_samples,\n", + " dataset_name=\"inference\",\n", + " task_name=\"length_of_stay\",\n", + " input_schema={\n", + " \"conditions\": SequenceProcessor,\n", + " \"procedures\": SequenceProcessor,\n", + " },\n", + " output_schema={\"label\": \"regression\"},\n", + " )\n", + "\n", + " # Create dataloader\n", + " inference_loader = get_dataloader(\n", + " inference_dataset,\n", + " batch_size=32,\n", + " shuffle=False\n", + " )\n", + "\n", + " # Make predictions\n", + " model.eval()\n", + " model.to(device)\n", + "\n", + " predictions = []\n", + "\n", + " with torch.no_grad():\n", + " for batch in inference_loader:\n", + " # Move to device\n", + " batch = {k: v.to(device) if torch.is_tensor(v) else v\n", + " for k, v in batch.items()}\n", + "\n", + " # Forward pass (label not used in forward when in eval mode)\n", + " outputs = model(**batch)\n", + " preds = outputs['y_prob'].cpu().numpy().flatten()\n", + " predictions.extend(preds)\n", + "\n", + " return predictions" + ], + "outputs": [], + "execution_count": 19 + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2025-11-24T21:58:11.021995Z", + "start_time": "2025-11-24T21:58:10.615409Z" + } + }, + "cell_type": "code", + "source": [ + "# ==============================================================================\n", + "# USAGE EXAMPLES\n", + "# ==============================================================================\n", + "\n", + "print(\"=\" * 80)\n", + "print(\"TPC MODEL INFERENCE\")\n", + "print(\"=\" * 80)\n", + "\n", + "# ===== Example 1: Simple Case =====\n", + "print(\"\\n1. Simple Single Patient Prediction\")\n", + "print(\"-\" * 80)\n", + "\n", + "single_patient = {\n", + " 'conditions': [1, 5, 12, 23, 45],\n", + " 'procedures': [2, 7],\n", + "}\n", + "\n", + "prediction = predict_new_patients(model, [single_patient], device='cpu')\n", + "\n", + "print(f\"Conditions: {single_patient['conditions']}\")\n", + "print(f\"Procedures: {single_patient['procedures']}\")\n", + "print(f\"Predicted LoS: {prediction[0]:.2f} days\")\n", + "\n", + "\n", + "# ===== Example 2: Batch with Patient IDs =====\n", + "print(\"\\n2. Batch Prediction with Patient IDs\")\n", + "print(\"-\" * 80)\n", + "\n", + "batch_patients = [\n", + " {\n", + " 'patient_id': 'patient-A',\n", + " 'conditions': [1, 2],\n", + " 'procedures': [1],\n", + " },\n", + " {\n", + " 'patient_id': 'patient-B',\n", + " 'conditions': [5, 10, 15, 20, 25],\n", + " 'procedures': [3, 7, 9],\n", + " },\n", + " {\n", + " 'patient_id': 'patient-C',\n", + " 'conditions': [8, 12, 16],\n", + " 'procedures': [2, 4],\n", + " }\n", + "]\n", + "\n", + "batch_predictions = predict_new_patients(model, batch_patients, device='cpu')\n", + "\n", + "for patient, pred in zip(batch_patients, batch_predictions):\n", + " print(f\"\\n{patient['patient_id']}:\")\n", + " print(f\" Conditions: {len(patient['conditions'])} codes\")\n", + " print(f\" Procedures: {len(patient['procedures'])} codes\")\n", + " print(f\" Predicted LoS: {pred:.2f} days\")\n", + "\n", + "\n", + "# ===== Real-World Format =====\n", + "print(\"\\n3. Real-World Scenario\")\n", + "print(\"-\" * 80)\n", + "\n", + "# Simulate data from your EHR system\n", + "ehr_patients = [\n", + " {\n", + " 'patient_id': 'MRN-12345',\n", + " 'visit_id': 'ADM-67890',\n", + " 'conditions': [1, 3, 5, 7, 9], # ICD codes\n", + " 'procedures': [2, 4], # CPT codes\n", + " },\n", + " {\n", + " 'patient_id': 'MRN-54321',\n", + " 'visit_id': 'ADM-09876',\n", + " 'conditions': [11, 13, 17],\n", + " 'procedures': [6, 8, 10],\n", + " }\n", + "]\n", + "\n", + "ehr_predictions = predict_new_patients(model, ehr_patients, device='cpu')\n", + "\n", + "print(\"\\nPredictions from EHR System:\")\n", + "for patient, pred in zip(ehr_patients, ehr_predictions):\n", + " print(f\"\\nMRN: {patient['patient_id']}\")\n", + " print(f\"Admission: {patient['visit_id']}\")\n", + " print(f\"Predicted LoS: {pred:.2f} days\")\n", + " print(f\"Expected discharge: Day {int(np.ceil(pred))}\")\n", + "\n", + "\n", + "print(\"\\n\" + \"=\" * 80)\n", + "print(\"INFERENCE COMPLETE\")\n", + "print(\"=\" * 80)" + ], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "================================================================================\n", + "TPC MODEL INFERENCE\n", + "================================================================================\n", + "\n", + "1. Simple Single Patient Prediction\n", + "--------------------------------------------------------------------------------\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Processing samples: 100%|██████████| 1/1 [00:00<00:00, 322.59it/s]\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Conditions: [1, 5, 12, 23, 45]\n", + "Procedures: [2, 7]\n", + "Predicted LoS: 0.49 days\n", + "\n", + "2. Batch Prediction with Patient IDs\n", + "--------------------------------------------------------------------------------\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Processing samples: 100%|██████████| 3/3 [00:00<00:00, 8744.21it/s]\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "patient-A:\n", + " Conditions: 2 codes\n", + " Procedures: 1 codes\n", + " Predicted LoS: 2.89 days\n", + "\n", + "patient-B:\n", + " Conditions: 5 codes\n", + " Procedures: 3 codes\n", + " Predicted LoS: 0.53 days\n", + "\n", + "patient-C:\n", + " Conditions: 3 codes\n", + " Procedures: 2 codes\n", + " Predicted LoS: 2.04 days\n", + "\n", + "3. Real-World Scenario\n", + "--------------------------------------------------------------------------------\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Processing samples: 100%|██████████| 2/2 [00:00<00:00, 2383.13it/s]\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Predictions from EHR System:\n", + "\n", + "MRN: MRN-12345\n", + "Admission: ADM-67890\n", + "Predicted LoS: 0.49 days\n", + "Expected discharge: Day 1\n", + "\n", + "MRN: MRN-54321\n", + "Admission: ADM-09876\n", + "Predicted LoS: 1.49 days\n", + "Expected discharge: Day 2\n", + "\n", + "================================================================================\n", + "INFERENCE COMPLETE\n", + "================================================================================\n" + ] + } + ], + "execution_count": 20 + }, + { + "metadata": {}, + "cell_type": "code", + "outputs": [], + "execution_count": null, + "source": [ + "# ==============================================================================\n", + "# 12. ANALYSIS AND INSIGHTS\n", + "# ==============================================================================" + ] + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2025-11-24T21:59:48.622734Z", + "start_time": "2025-11-24T21:59:42.298116Z" + } + }, + "cell_type": "code", + "source": [ + "print(\"\\n\" + \"=\" * 80)\n", + "print(\"MODEL INSIGHTS\")\n", + "print(\"=\" * 80)\n", + "\n", + "# Compute comprehensive metrics on test set\n", + "all_predictions = []\n", + "all_ground_truth = []\n", + "\n", + "model.eval()\n", + "with torch.no_grad():\n", + " for batch in test_loader:\n", + " outputs = model(**batch)\n", + " predictions = outputs[\"y_prob\"].cpu().numpy()\n", + " labels = batch[\"label\"].cpu().numpy()\n", + "\n", + " all_predictions.extend(predictions.flatten())\n", + " all_ground_truth.extend(labels.flatten())\n", + "\n", + "all_predictions = np.array(all_predictions)\n", + "all_ground_truth = np.array(all_ground_truth)\n", + "\n", + "# Calculate comprehensive metrics\n", + "mae = mean_absolute_error(all_ground_truth, all_predictions)\n", + "mse = mean_squared_error(all_ground_truth, all_predictions)\n", + "rmse = np.sqrt(mse)\n", + "r2 = r2_score(all_ground_truth, all_predictions)\n", + "\n", + "# Calculate percentage of predictions within X days\n", + "within_1_day = np.mean(np.abs(all_predictions - all_ground_truth) <= 1) * 100\n", + "within_2_days = np.mean(np.abs(all_predictions - all_ground_truth) <= 2) * 100\n", + "within_3_days = np.mean(np.abs(all_predictions - all_ground_truth) <= 3) * 100\n", + "\n", + "print(\"\\nComprehensive Performance Metrics:\")\n", + "print(\"-\" * 40)\n", + "print(f\"Mean Absolute Error (MAE): {mae:.3f} days\")\n", + "print(f\"Mean Squared Error (MSE): {mse:.3f}\")\n", + "print(f\"Root Mean Squared Error (RMSE): {rmse:.3f} days\")\n", + "print(f\"R² Score: {r2:.3f}\")\n", + "\n", + "print(\"\\nClinical Utility:\")\n", + "print(f\" Predictions within ±1 day: {within_1_day:.1f}%\")\n", + "print(f\" Predictions within ±2 days: {within_2_days:.1f}%\")\n", + "print(f\" Predictions within ±3 days: {within_3_days:.1f}%\")" + ], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "================================================================================\n", + "MODEL INSIGHTS\n", + "================================================================================\n", + "\n", + "Comprehensive Performance Metrics:\n", + "----------------------------------------\n", + "Mean Absolute Error (MAE): 2.033 days\n", + "Mean Squared Error (MSE): 6.292\n", + "Root Mean Squared Error (RMSE): 2.508 days\n", + "R² Score: -0.297\n", + "\n", + "Clinical Utility:\n", + " Predictions within ±1 day: 29.3%\n", + " Predictions within ±2 days: 54.7%\n", + " Predictions within ±3 days: 78.0%\n" + ] + } + ], + "execution_count": 21 + }, + { + "metadata": {}, + "cell_type": "code", + "outputs": [], + "execution_count": null, + "source": [ + "# ==============================================================================\n", + "# 13. SAVING AND LOADING MODEL\n", + "# ==============================================================================" + ] + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2025-11-24T22:01:26.406191Z", + "start_time": "2025-11-24T22:01:26.160184Z" + } + }, + "cell_type": "code", + "source": [ + "print(\"\\n\" + \"=\" * 80)\n", + "print(\"MODEL PERSISTENCE\")\n", + "print(\"=\" * 80)\n", + "\n", + "# Save model\n", + "model_path = \"./outputs/models/tpc_model.pth\"\n", + "os.makedirs(os.path.dirname(model_path), exist_ok=True)\n", + "torch.save(model.state_dict(), model_path)\n", + "print(f\"\\n Model saved to: {model_path}\")\n", + "\n", + "# Example of loading model\n", + "loaded_model = TPC(\n", + " dataset=dataset,\n", + " embedding_dim=128,\n", + " num_layers=3,\n", + " num_filters=8,\n", + " pointwise_channels=128,\n", + " kernel_size=4,\n", + " dropout=0.3\n", + ")\n", + "loaded_model.load_state_dict(torch.load(model_path))\n", + "loaded_model.eval()\n", + "print(f\" Model loaded successfully\")" + ], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "================================================================================\n", + "MODEL PERSISTENCE\n", + "================================================================================\n", + "\n", + " Model saved to: ./outputs/models/tpc_model.pth\n", + " Model loaded successfully\n" + ] + } + ], + "execution_count": 23 + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3.10 (PyHealth)", + "language": "python", + "name": "pyhealth_env" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/pyhealth/models/__init__.py b/pyhealth/models/__init__.py index 5c3683bc1..dd4bb877b 100644 --- a/pyhealth/models/__init__.py +++ b/pyhealth/models/__init__.py @@ -26,4 +26,5 @@ from .transformer import Transformer, TransformerLayer from .transformers_model import TransformersModel from .vae import VAE -from .sdoh import SdohClassifier \ No newline at end of file +from .sdoh import SdohClassifier +from .tpc import TPC, TPCBlock \ No newline at end of file diff --git a/pyhealth/models/tpc.py b/pyhealth/models/tpc.py new file mode 100644 index 000000000..5157781b6 --- /dev/null +++ b/pyhealth/models/tpc.py @@ -0,0 +1,628 @@ +"""Temporal Pointwise Convolutional Networks (TPC) for PyHealth. + +Paper: Temporal Pointwise Convolutional Networks for Length of Stay Prediction + in the Intensive Care Unit +Paper Link: https://arxiv.org/abs/2101.10043 (CHIL 2021) +Authors: Emma Rocheteau, Catherine Schwarz, Ari Ercole, Pietro Liò, Stephanie Hyland +GitHub: https://github.com/EmmaRocheteau/TPC-LoS-prediction + +Implementation Authors: Zakaria Coulibaly +NetID: zakaria5 + +Description: + State-of-the-art implementation of TPC for healthcare time series prediction. + TPC naturally handles irregular time sampling through temporal convolutions + combined with pointwise (1x1) convolutions, making it ideal for ICU data and + other irregularly-sampled medical time series. + +Key Features: + - Temporal convolutions with increasing dilation for multi-scale patterns + - Pointwise convolutions for feature interactions + - Dense skip connections within each layer + - Support for irregular time series and variable-length sequences + - Configurable loss functions (PyHealth standard MSE recommended for stability) + +Architecture: + Each TPC layer consists of: + 1. Temporal convolution (grouped, one per input feature) + 2. Pointwise (1x1) convolution for feature mixing + 3. Dense skip connections combining: [input, temporal_out, pointwise_out] + + Layers are stacked with each layer's output feeding into the next, + creating a progressively deeper representation. +""" + +from typing import Dict, Optional + +import torch +import torch.nn as nn +import torch.nn.functional as F + +from pyhealth.datasets import SampleDataset +from pyhealth.models import BaseModel +from pyhealth.models import EmbeddingModel + + +class TPCBlock(nn.Module): + """Single Temporal Pointwise Convolutional Block. + + Implements one TPC layer as described in Section 2 and Figure 3 of the paper. + Each block consists of: + 1. Temporal (grouped) convolution - captures time-series patterns + 2. Pointwise (1x1) convolution - enables feature interactions + 3. Dense skip connections - concatenates [input, temp_out, point_out] + + The temporal convolution uses grouped convolutions where each feature + has its own convolutional kernel, preserving feature independence while + capturing temporal patterns. + + Args: + input_channels (int): Number of input channels/features. + num_filters (int): Number of filters per temporal convolution. + Default is 8 as in the paper. + kernel_size (int): Temporal convolution kernel size. Default is 4 + as in the paper. + dilation (int): Dilation rate for temporal convolution. Increases + with layer depth for multi-scale pattern capture. Default is 1. + pointwise_channels (int): Output channels for pointwise convolution. + Default is 128 as in the paper. + dropout (float): Dropout probability applied after each convolution. + Default is 0.3 as in the paper. + + Attributes: + temporal_conv (nn.Conv1d): Grouped 1D convolution for temporal patterns. + pointwise_conv (nn.Linear): Linear layer implementing 1x1 convolution. + bn_temporal (nn.BatchNorm1d): Batch normalization for temporal features. + bn_pointwise (nn.BatchNorm1d): Batch normalization for pointwise features. + output_channels (int): Total output channels after skip connections. + + Shape: + - Input: (batch_size, input_channels, sequence_length) + - Output: (batch_size, output_channels, sequence_length) + + where output_channels = input_channels + (input_channels * num_filters) + + pointwise_channels + + Examples: + >>> import torch + >>> block = TPCBlock(input_channels=128, num_filters=8, kernel_size=4) + >>> x = torch.randn(32, 128, 50) # batch=32, channels=128, seq_len=50 + >>> output = block(x) + >>> output.shape + torch.Size([32, 384, 50]) + """ + + def __init__( + self, + input_channels: int, + num_filters: int = 8, + kernel_size: int = 4, + dilation: int = 1, + pointwise_channels: int = 128, + dropout: float = 0.3, + ): + """Initializes TPCBlock. + + Args: + input_channels: Number of input channels. + num_filters: Number of filters per temporal convolution. + kernel_size: Temporal convolution kernel size. + dilation: Dilation rate for temporal convolution. + pointwise_channels: Output channels for pointwise convolution. + dropout: Dropout probability. + """ + super(TPCBlock, self).__init__() + + self.input_channels = input_channels + self.num_filters = num_filters + self.kernel_size = kernel_size + self.dilation = dilation + self.pointwise_channels = pointwise_channels + + # Temporal convolution (grouped - one filter per input channel) + # This preserves feature independence as described in Section 2.1 + self.temporal_conv = nn.Conv1d( + in_channels=input_channels, + out_channels=input_channels * num_filters, + kernel_size=kernel_size, + dilation=dilation, + groups=input_channels, # Key: each feature has independent weights + padding=0, # Use causal padding manually + bias=True, + ) + + # Batch normalization for temporal features + self.bn_temporal = nn.BatchNorm1d( + input_channels * num_filters, track_running_stats=True + ) + + # Pointwise (1x1) convolution for feature mixing + # Implemented as Linear layer as in paper's reference code + self.pointwise_conv = nn.Linear(input_channels, pointwise_channels) + + # Batch normalization for pointwise features + self.bn_pointwise = nn.BatchNorm1d( + pointwise_channels, track_running_stats=True + ) + + # Dropout layers + self.dropout_temporal = nn.Dropout(dropout) + self.dropout_pointwise = nn.Dropout(dropout) + + # Output dimension after skip connections (concatenation) + self.output_channels = ( + input_channels + (input_channels * num_filters) + pointwise_channels + ) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + """Forward pass through TPC block. + + Applies temporal convolution, pointwise convolution, and combines + them with skip connections as described in Section 2.1 of the paper. + + Args: + x: Input tensor of shape (batch_size, input_channels, sequence_length). + + Returns: + torch.Tensor: Output tensor with skip connections, shape + (batch_size, output_channels, sequence_length). + """ + batch_size, channels, seq_length = x.shape + + # === Temporal Convolution Branch === + # Apply causal padding to preserve temporal ordering + # Padding amount ensures output length matches input length + padding_amount = (self.kernel_size - 1) * self.dilation + x_padded = F.pad(x, (padding_amount, 0), mode="constant", value=0) + + # Apply temporal convolution + temporal_out = self.temporal_conv(x_padded) + + # Batch normalization - handle edge case of single timestep + if self.training and seq_length == 1: + # Switch to eval mode temporarily to avoid batch norm error + self.bn_temporal.eval() + temporal_out = self.bn_temporal(temporal_out) + self.bn_temporal.train() + else: + temporal_out = self.bn_temporal(temporal_out) + + temporal_out = self.dropout_temporal(temporal_out) + + # === Pointwise Convolution Branch === + # Reshape for pointwise: (batch_size, sequence_length, input_channels) + x_transposed = x.permute(0, 2, 1) + + # Apply pointwise convolution (1x1 conv implemented as Linear) + pointwise_out = self.pointwise_conv(x_transposed) + pointwise_out = pointwise_out.permute(0, 2, 1) # Back to (B, C, T) + + # Batch normalization - handle single timestep edge case + if self.training and seq_length == 1: + self.bn_pointwise.eval() + pointwise_out = self.bn_pointwise(pointwise_out) + self.bn_pointwise.train() + else: + pointwise_out = self.bn_pointwise(pointwise_out) + + pointwise_out = self.dropout_pointwise(pointwise_out) + + # === Dense Skip Connections === + # Concatenate [original_input, temporal_features, pointwise_features] + # This is the key innovation: preserving information through layers + output = torch.cat([x, temporal_out, pointwise_out], dim=1) + + # Apply ReLU activation + output = F.relu(output) + + return output + + +class TPC(BaseModel): + """Temporal Pointwise Convolutional Network for healthcare prediction. + + Full implementation of the TPC architecture from: + "Temporal Pointwise Convolutional Networks for Length of Stay Prediction + in the Intensive Care Unit" (Rocheteau et al., CHIL 2021) + + The model processes irregularly-sampled time series data through stacked + TPC blocks, each combining temporal and pointwise convolutions with dense + skip connections. The final representation is obtained by taking the last + valid timestep for each sequence. + + Key Advantages: + - Handles irregular time sampling naturally + - Captures multi-scale temporal patterns via dilation + - Enables rich feature interactions via pointwise convolutions + - Efficient on long sequences due to causal convolutions + - Supports variable-length sequences with masking + + Architecture Details: + 1. Embedding layer: Converts categorical features to dense vectors + 2. TPC blocks: Stacked layers with increasing dilation rates + 3. Temporal pooling: Extract last valid timestep representation + 4. Classification/Regression head: Final prediction layer + + Loss Function: + Uses PyHealth's standard losses: + - MSE for regression tasks + - Binary cross-entropy for binary classification + - Cross-entropy for multiclass classification + + Note: The original paper uses masked MSLE loss, but that approach is + designed for sequence-to-sequence prediction. This implementation performs + sequence-to-one prediction (single LoS value per patient), which already + handles variable-length sequences by extracting the last valid timestep. + + Args: + dataset (SampleDataset): PyHealth dataset with input/output schemas. + Must contain at least one feature key and one label key. + embedding_dim (int): Dimension for embedding categorical features. + Default is 128. + num_layers (int): Number of TPC blocks to stack. Default is 3 as in paper. + num_filters (int): Number of filters per temporal convolution. Default + is 8 as in paper. + pointwise_channels (int): Channels for pointwise convolutions. Default + is 128 as in paper. + kernel_size (int): Temporal convolution kernel size. Default is 4 as + in paper. + dropout (float): Dropout probability. Default is 0.3 as in paper. + **kwargs: Additional arguments passed to BaseModel. + + Attributes: + embedding_model (EmbeddingModel): Handles feature embeddings. + tpc_blocks (nn.ModuleList): Stack of TPC blocks. + fc (nn.Linear): Final classification/regression layer. + mode (str): Task mode - "regression", "binary", or "multiclass". + + Raises: + ValueError: If num_layers < 1, embedding_dim < 1, or num_filters < 1. + AssertionError: If dataset does not have exactly one label key. + + Examples: + >>> # Standard usage + >>> samples = [{"patient_id": "p1", "conditions": [...], "label": 3.5}] + >>> dataset = SampleDataset(samples=samples, ...) + >>> model = TPC(dataset=dataset) + >>> + >>> # Custom hyperparameters + >>> model = TPC( + ... dataset=dataset, + ... embedding_dim=256, + ... num_layers=5, + ... num_filters=12, + ... dropout=0.4 + ... ) + + """ + + def __init__( + self, + dataset: SampleDataset, + embedding_dim: int = 128, + num_layers: int = 3, + num_filters: int = 8, + pointwise_channels: int = 128, + kernel_size: int = 4, + dropout: float = 0.3, + **kwargs + ): + """Initializes TPC model. + + Args: + dataset: PyHealth dataset with input/output schemas. + embedding_dim: Dimension for embedding categorical features. + num_layers: Number of TPC blocks to stack. + num_filters: Number of filters per temporal convolution. + pointwise_channels: Channels for pointwise convolutions. + kernel_size: Temporal convolution kernel size. + dropout: Dropout probability. + **kwargs: Additional arguments for BaseModel. + + Raises: + ValueError: If hyperparameters are invalid. + """ + super(TPC, self).__init__(dataset=dataset, **kwargs) + + # Validate configuration + if num_layers < 1: + raise ValueError(f"num_layers must be >= 1, got {num_layers}") + if embedding_dim < 1: + raise ValueError(f"embedding_dim must be >= 1, got {embedding_dim}") + if num_filters < 1: + raise ValueError(f"num_filters must be >= 1, got {num_filters}") + + self.embedding_dim = embedding_dim + self.num_layers = num_layers + self.num_filters = num_filters + self.pointwise_channels = pointwise_channels + self.kernel_size = kernel_size + self.dropout = dropout + + # Get label key from dataset + assert len(self.label_keys) == 1, "TPC supports only one label key" + self.label_key = self.label_keys[0] + + # Use PyHealth's EmbeddingModel for handling embeddings + self.embedding_model = EmbeddingModel( + dataset=dataset, embedding_dim=embedding_dim + ) + + # Build TPC blocks with increasing dilation + self.tpc_blocks = nn.ModuleList() + + # Initial input dimension: num_features * embedding_dim + current_channels = len(self.feature_keys) * embedding_dim + + for layer_idx in range(num_layers): + # Dilation increases with depth for multi-scale patterns + # As per paper Section 2.1: d = 1 + n(k-1) where n is layer index + dilation = 1 + layer_idx * (kernel_size - 1) + + block = TPCBlock( + input_channels=current_channels, + num_filters=num_filters, + kernel_size=kernel_size, + dilation=dilation, + pointwise_channels=pointwise_channels, + dropout=dropout, + ) + + self.tpc_blocks.append(block) + + # Update channels for next layer (due to skip connections) + current_channels = block.output_channels + + # Final classification/regression layer + output_size = self.get_output_size() + self.fc = nn.Linear(current_channels, output_size) + + def forward(self, **kwargs) -> Dict[str, torch.Tensor]: + """Forward propagation through TPC model. + + Processes input features through embeddings, TPC blocks, temporal pooling, + and final prediction layer. Handles variable-length sequences via masking. + + Args: + **kwargs: Dictionary containing: + - Feature keys (from self.feature_keys): Input feature tensors + - Label key (from self.label_key): Ground truth labels (optional) + - "embed" (bool): If True, return patient embeddings (optional) + + Returns: + Dict[str, torch.Tensor]: Dictionary containing: + - "y_prob": Predicted probabilities/values, shape (batch_size, output_size) + - "logit": Raw model outputs before activation, shape (batch_size, output_size) + - "loss": Loss value (scalar), only if labels provided + - "y_true": Ground truth labels, only if labels provided + - "embed": Patient embeddings, only if embed=True in kwargs + + Examples: + >>> # Forward pass during training + >>> batch = { + ... "conditions": tensor([[1, 2, 3], [4, 5, 0]]), + ... "procedures": tensor([[10, 11], [12, 13]]), + ... "label": tensor([3.5, 7.2]) + ... } + >>> outputs = model(**batch) + >>> loss = outputs["loss"] + >>> predictions = outputs["y_prob"] + """ + # Get batch size and device from first feature + batch_size = kwargs[self.feature_keys[0]].shape[0] + device = kwargs[self.feature_keys[0]].device + + # === Step 1: Embed all features using EmbeddingModel === + embedded_dict = self.embedding_model(kwargs) + + # === Step 2: Find maximum sequence length across all features === + max_seq_len = 0 + for feature_key in self.feature_keys: + seq_len = embedded_dict[feature_key].shape[1] + max_seq_len = max(max_seq_len, seq_len) + + # === Step 3: Pad sequences to same length and create masks === + padded_features = [] + padded_masks = [] + + for feature_key in self.feature_keys: + embedded = embedded_dict[feature_key] # (batch, seq_len, embedding_dim) + current_len = embedded.shape[1] + + # Pad if necessary + if current_len < max_seq_len: + pad_len = max_seq_len - current_len + embedded = F.pad(embedded, (0, 0, 0, pad_len), value=0) + + padded_features.append(embedded) + + # Create mask (1 for valid tokens, 0 for padding) + mask = torch.ones(batch_size, max_seq_len, device=device) + if current_len < max_seq_len: + mask[:, current_len:] = 0 + + # Also check for actual padding in original data + # Sum over embedding dimension: if all zeros, it's padding + original_mask = (embedded_dict[feature_key].sum(dim=-1) != 0).float() + mask[:, :current_len] = original_mask + + padded_masks.append(mask) + + # === Step 4: Concatenate features === + # Shape: (batch, max_seq_len, num_features * embedding_dim) + x = torch.cat(padded_features, dim=2) + + # Transpose to (batch, channels, seq_length) for convolutions + x = x.permute(0, 2, 1) + + # Combined mask: valid if ANY feature is valid at that timestep + combined_mask = torch.stack(padded_masks, dim=1).max(dim=1)[0] + + # === Step 5: Process through TPC blocks === + for block in self.tpc_blocks: + x = block(x) + + # === Step 6: Global temporal pooling === + # x shape: (batch, final_channels, seq_length) + x = x.permute(0, 2, 1) # (batch, seq_length, final_channels) + + # Get indices of last valid timestep for each sequence + seq_lengths = combined_mask.sum(dim=1).long() # (batch,) + last_indices = (seq_lengths - 1).clamp(min=0) + + # Extract last valid representation for each sequence + patient_embedding = x[torch.arange(batch_size, device=device), last_indices] + # Shape: (batch, final_channels) + + # === Step 7: Final prediction === + logits = self.fc(patient_embedding) + + # === Step 8: Compute loss and predictions === + if self.label_key in kwargs: + y_true = kwargs[self.label_key] + + # Handle label shapes - ensure proper dimensions + if y_true.dim() == 1: + y_true = y_true.unsqueeze(1) + if logits.dim() == 1: + logits = logits.unsqueeze(1) + + # Compute loss based on task mode and loss function choice + if self.mode == "regression": + # Use PyHealth's standard MSE loss + loss = F.mse_loss(logits, y_true) + else: + # Classification: use standard cross-entropy from PyHealth + loss_fn = self.get_loss_function() + if self.mode == "binary": + loss = loss_fn(logits, y_true.float()) + else: + loss = loss_fn(logits, y_true.squeeze(1).long()) + else: + loss = None + y_true = None + + # Prepare predictions (convert logits to probabilities based on mode) + y_prob = self.prepare_y_prob(logits) + + # Build output dictionary + results = {"y_prob": y_prob, "logit": logits} + + if loss is not None: + results["loss"] = loss + if y_true is not None: + results["y_true"] = y_true + + # Optionally return embeddings for analysis + if kwargs.get("embed", False): + results["embed"] = patient_embedding + + return results + + +if __name__ == "__main__": + """Test TPC model with dummy data. + + This main function demonstrates how to use the TPC model with PyHealth, + showing both standard PyHealth loss and the paper's masked MSLE loss. + """ + # Create dummy dataset + from pyhealth.datasets import SampleDataset + from pyhealth.processors import SequenceProcessor + from pyhealth.datasets import SampleDataset, split_by_patient, get_dataloader + + samples = [ + { + "patient_id": f"patient-{i}", + "visit_id": f"visit-{i}", + "conditions": [f"ICD{j}" for j in range(3)], + "procedures": [f"PROC{j}" for j in range(2)], + "label": float(i % 10 + 1), # LoS between 1-10 days + } + for i in range(100) + ] + + # Create PyHealth SampleDataset with proper schemas + dataset = SampleDataset( + samples=samples, + dataset_name="test_tpc", + task_name="length_of_stay", + input_schema={ + "conditions": SequenceProcessor, + "procedures": SequenceProcessor, + }, + output_schema={"label": "regression"}, + ) + + # Initialize the model + print("=" * 80) + print("TPC Model Test - Standard PyHealth Loss") + print("=" * 80) + + # Standard PyHealth loss (default) + model_standard = TPC( + dataset=dataset, + embedding_dim=128, # Embedding dimension for medical codes + num_layers=3, # Number of TPC blocks (default from paper) + num_filters=8, # Filters per temporal convolution (paper default) + pointwise_channels=128, # Channels for pointwise convolutions (paper default) + kernel_size=4, # Temporal convolution kernel size (paper default) + dropout=0.3, # Dropout rate (paper default) + ) + + print(f"Model initialized successfully!") + print(f"Number of parameters: {sum(p.numel() for p in model_standard.parameters()):,}") + print(f"Feature keys: {model_standard.feature_keys}") + print(f"Label key: {model_standard.label_key}") + print(f"Mode: {model_standard.mode}") + print(f"Output size: {model_standard.get_output_size()}") + print(f"Loss function: PyHealth standard MSE") + + # Spliting the data train/val/test + train_dataset, val_dataset, test_dataset = split_by_patient( + dataset, ratios=[0.7, 0.15, 0.15], seed=42 + ) + + print("\nDataset Split:") + print(f" Training samples: {len(train_dataset)}") + print(f" Validation samples: {len(val_dataset)}") + print(f" Test samples: {len(test_dataset)}") + + # Create dataloader for the train dataset + train_loader = get_dataloader(train_dataset, batch_size=8, shuffle=True) + + # Get a real batch from the dataloader + batch = next(iter(train_loader)) + + # Forward pass + print("\n" + "=" * 80) + print("Running forward pass...") + print("=" * 80) + outputs = model_standard(**batch) + print(f"\nLoss (Standard MSE): {outputs['loss'].item():.4f}") + print(f"Predictions shape: {outputs['y_prob'].shape}") + print(f"Sample predictions: {outputs['y_prob'][:3].flatten().tolist()}") + print(f"Sample ground truth: {outputs['y_true'][:3].flatten().tolist()}") + + print("\n" + "=" * 80) + print("Test completed successfully!") + print("=" * 80) + + + + + + + + + + + + + + + + + + + From afd3cf2174be35484729464d088fe9947993f222 Mon Sep 17 00:00:00 2001 From: Zakaria Coulibaly <47373846+levisstrauss@users.noreply.github.com> Date: Tue, 25 Nov 2025 23:32:56 -0500 Subject: [PATCH 2/5] Fix paper link in tpc.py documentation Updated paper link in TPC model documentation. --- pyhealth/models/tpc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyhealth/models/tpc.py b/pyhealth/models/tpc.py index 5157781b6..10e246b01 100644 --- a/pyhealth/models/tpc.py +++ b/pyhealth/models/tpc.py @@ -2,7 +2,7 @@ Paper: Temporal Pointwise Convolutional Networks for Length of Stay Prediction in the Intensive Care Unit -Paper Link: https://arxiv.org/abs/2101.10043 (CHIL 2021) +Paper Link: https://arxiv.org/pdf/2007.09483 Authors: Emma Rocheteau, Catherine Schwarz, Ari Ercole, Pietro Liò, Stephanie Hyland GitHub: https://github.com/EmmaRocheteau/TPC-LoS-prediction From 9f1697c9e87355073af36e45e2c6fdbd9f7ee5f1 Mon Sep 17 00:00:00 2001 From: Zakaria Coulibaly <47373846+levisstrauss@users.noreply.github.com> Date: Tue, 25 Nov 2025 23:33:56 -0500 Subject: [PATCH 3/5] Update paper link in tpc_example.ipynb Update paper link in tpc_example.ipynb --- examples/tpc_example.ipynb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/tpc_example.ipynb b/examples/tpc_example.ipynb index 17b787104..8842cf82e 100644 --- a/examples/tpc_example.ipynb +++ b/examples/tpc_example.ipynb @@ -21,7 +21,7 @@ "\n", "Paper: \"Temporal Pointwise Convolutional Networks for Length of Stay Prediction\n", " in the Intensive Care Unit\" (Rocheteau et al., CHIL 2021)\n", - "Paper Link: https://arxiv.org/abs/2101.10043\n", + "Paper https://arxiv.org/pdf/2007.09483 "\n", "Implementation Authors: Zakaria Coulibaly\n", "NetID: zakaria5\n", From 4138daac294dc7ce5da7964ee9599be51af67bdf Mon Sep 17 00:00:00 2001 From: Zakaria Coulibaly <47373846+levisstrauss@users.noreply.github.com> Date: Tue, 25 Nov 2025 23:37:56 -0500 Subject: [PATCH 4/5] Fix formatting of paper reference in notebook --- examples/tpc_example.ipynb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/examples/tpc_example.ipynb b/examples/tpc_example.ipynb index 8842cf82e..98c51f3d1 100644 --- a/examples/tpc_example.ipynb +++ b/examples/tpc_example.ipynb @@ -21,8 +21,7 @@ "\n", "Paper: \"Temporal Pointwise Convolutional Networks for Length of Stay Prediction\n", " in the Intensive Care Unit\" (Rocheteau et al., CHIL 2021)\n", - "Paper https://arxiv.org/pdf/2007.09483 - "\n", + "Paper https://arxiv.org/pdf/2007.09483\n", "Implementation Authors: Zakaria Coulibaly\n", "NetID: zakaria5\n", "\n", From 75cbb1fa7c869eea6ac276d4a140a10aca1a7ae1 Mon Sep 17 00:00:00 2001 From: Zakaria Coulibaly <47373846+levisstrauss@users.noreply.github.com> Date: Tue, 25 Nov 2025 23:52:43 -0500 Subject: [PATCH 5/5] Fix formatting of paper citation in notebook --- examples/tpc_example.ipynb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/tpc_example.ipynb b/examples/tpc_example.ipynb index 98c51f3d1..552b9239a 100644 --- a/examples/tpc_example.ipynb +++ b/examples/tpc_example.ipynb @@ -21,7 +21,7 @@ "\n", "Paper: \"Temporal Pointwise Convolutional Networks for Length of Stay Prediction\n", " in the Intensive Care Unit\" (Rocheteau et al., CHIL 2021)\n", - "Paper https://arxiv.org/pdf/2007.09483\n", + "Paper: https://arxiv.org/pdf/2007.09483\n", "Implementation Authors: Zakaria Coulibaly\n", "NetID: zakaria5\n", "\n",