"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "aug_ecg = augmenter(preprocessor(keras.ops.convert_to_tensor(np.reshape(ecg, (1, -1, 1)))), training=True)\n",
+ "aug_ecg = aug_ecg.numpy().squeeze()\n",
+ "\n",
+ "ts = np.arange(0, len(aug_ecg)) / sampling_rate\n",
+ "fig, ax = plt.subplots(1, 1, figsize=(9, 4))\n",
+ "plt.plot(ts, aug_ecg, color=plot_theme.primary_color, lw=3)\n",
+ "fig.suptitle(\"Augmented ECG Signal\")\n",
"ax.set_xlabel(\"Time (s)\")\n",
"ax.set_ylabel(\"Amplitude\")\n",
- "plt.show()\n"
+ "plt.tight_layout()\n",
+ "plt.show()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Create full data pipeline w/ augmentation\n",
+ "\n",
+ "We will now create a full data pipeline by extended the original with shuffling, batching, augmentations, and prefetching.\n",
+ "\n",
+ "For validation, we will cache a subset of the validation data to speed up the evaluation process."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 11,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "train_ds = train_ds.shuffle(\n",
+ " buffer_size=buffer_size,\n",
+ " reshuffle_each_iteration=True,\n",
+ ").batch(\n",
+ " batch_size=batch_size,\n",
+ " drop_remainder=True,\n",
+ " num_parallel_calls=tf.data.AUTOTUNE,\n",
+ ").map(\n",
+ " preprocessor,\n",
+ " num_parallel_calls=tf.data.AUTOTUNE\n",
+ ").map(\n",
+ " lambda x: (augmenter(x, training=True), x),\n",
+ " num_parallel_calls=tf.data.AUTOTUNE\n",
+ ").prefetch(\n",
+ " tf.data.AUTOTUNE\n",
+ ")\n",
+ "\n",
+ "val_ds = val_ds.batch(\n",
+ " batch_size=batch_size,\n",
+ " drop_remainder=True,\n",
+ " num_parallel_calls=tf.data.AUTOTUNE,\n",
+ ").map(\n",
+ " preprocessor,\n",
+ " num_parallel_calls=tf.data.AUTOTUNE\n",
+ ").map(\n",
+ " lambda x: (augmenter(x, training=True), x),\n",
+ " num_parallel_calls=tf.data.AUTOTUNE\n",
+ ").prefetch(\n",
+ " tf.data.AUTOTUNE\n",
+ ")\n",
+ "\n",
+ "# Cache the validation dataset\n",
+ "val_ds = val_ds.take(val_size//batch_size).cache()"
]
},
{
@@ -236,20 +507,21 @@
"source": [
"## Define TCN model architecture\n",
"\n",
- "For this task, we are going to leverage a customized __TCN__ model architecture that is smaller and can handle 1D signals. The model consists of 4 TCN blocks with a depth of 1. Each block leverages dilated depthwise-separable convolutions along with inverted expansion and squeeze and excitation layers. The model is followed by a 1D convolutional layer and a final dense layer for regression. "
+ "For this task, we are going to leverage a customized __TCN__ model architecture that is smaller and can handle 1D signals. The model consists of 5 TCN blocks with a depth of 1. Each block leverages dilated depthwise-separable convolutions along with inverted expansion and squeeze and excitation layers. The model is followed by a 1D convolutional layer. "
]
},
{
"cell_type": "code",
- "execution_count": 6,
+ "execution_count": 12,
"metadata": {},
"outputs": [],
"source": [
"mbconv_blocks = [\n",
- " dict(depth=1, branch=1, filters=8, kernel=(1, 7), dilation=(1, 1), dropout=0, ex_ratio=1, se_ratio=0, norm=\"batch\"),\n",
- " dict(depth=1, branch=1, filters=16, kernel=(1, 7), dilation=(1, 1), dropout=0, ex_ratio=1, se_ratio=2, norm=\"batch\"),\n",
- " dict(depth=1, branch=1, filters=24, kernel=(1, 7), dilation=(1, 2), dropout=0, ex_ratio=1, se_ratio=2, norm=\"batch\"),\n",
- " dict(depth=1, branch=1, filters=32, kernel=(1, 7), dilation=(1, 4), dropout=0, ex_ratio=1, se_ratio=2, norm=\"batch\")\n",
+ " dict(depth=1, branch=1, filters=16, kernel=(1, 7), dilation=(1, 1), dropout=0, ex_ratio=1, se_ratio=0, norm=\"batch\"),\n",
+ " dict(depth=1, branch=1, filters=24, kernel=(1, 7), dilation=(1, 1), dropout=0, ex_ratio=1, se_ratio=2, norm=\"batch\"),\n",
+ " dict(depth=1, branch=1, filters=32, kernel=(1, 7), dilation=(1, 2), dropout=0, ex_ratio=1, se_ratio=2, norm=\"batch\"),\n",
+ " dict(depth=1, branch=1, filters=40, kernel=(1, 7), dilation=(1, 4), dropout=0, ex_ratio=1, se_ratio=2, norm=\"batch\"),\n",
+ " dict(depth=1, branch=1, filters=48, kernel=(1, 7), dilation=(1, 8), dropout=0, ex_ratio=1, se_ratio=2, norm=\"batch\")\n",
"]\n",
"\n",
"architecture = dict(\n",
@@ -277,7 +549,7 @@
},
{
"cell_type": "code",
- "execution_count": 7,
+ "execution_count": 13,
"metadata": {},
"outputs": [
{
@@ -318,53 +590,53 @@
"│ B1.D1.DW.ACT │ (None , 1 , 256 , 1 ) │ 0 │ B1.D1.DW.B1.BN[0 … │\n",
"│ (Activation ) │ │ │ │\n",
"├─────────────────────┼───────────────────┼────────────┼───────────────────┤\n",
- "│ B1.D1.PW.B1.CN │ (None , 1 , 256 , 8 ) │ 8 │ B1.D1.DW.ACT[0 ][… │\n",
- "│ (Conv2D ) │ │ │ │\n",
+ "│ B1.D1.PW.B1.CN │ (None , 1 , 256 , │ 16 │ B1.D1.DW.ACT[0 ][… │\n",
+ "│ (Conv2D ) │ 16 ) │ │ │\n",
"├─────────────────────┼───────────────────┼────────────┼───────────────────┤\n",
- "│ B1.D1.PW.B1.BN │ (None , 1 , 256 , 8 ) │ 32 │ B1.D1.PW.B1.CN[0 … │\n",
- "│ (BatchNormalizatio… │ │ │ │\n",
+ "│ B1.D1.PW.B1.BN │ (None , 1 , 256 , │ 64 │ B1.D1.PW.B1.CN[0 … │\n",
+ "│ (BatchNormalizatio… │ 16 ) │ │ │\n",
"├─────────────────────┼───────────────────┼────────────┼───────────────────┤\n",
- "│ B1.D1.PW.ACT │ (None , 1 , 256 , 8 ) │ 0 │ B1.D1.PW.B1.BN[0 … │\n",
- "│ (Activation ) │ │ │ │\n",
+ "│ B1.D1.PW.ACT │ (None , 1 , 256 , │ 0 │ B1.D1.PW.B1.BN[0 … │\n",
+ "│ (Activation ) │ 16 ) │ │ │\n",
"├─────────────────────┼───────────────────┼────────────┼───────────────────┤\n",
- "│ B2.D1.DW.B1.CN │ (None , 1 , 256 , 8 ) │ 56 │ B1.D1.PW.ACT[0 ][… │\n",
- "│ (DepthwiseConv2D ) │ │ │ │\n",
+ "│ B2.D1.DW.B1.CN │ (None , 1 , 256 , │ 112 │ B1.D1.PW.ACT[0 ][… │\n",
+ "│ (DepthwiseConv2D ) │ 16 ) │ │ │\n",
"├─────────────────────┼───────────────────┼────────────┼───────────────────┤\n",
- "│ B2.D1.DW.B1.BN │ (None , 1 , 256 , 8 ) │ 32 │ B2.D1.DW.B1.CN[0 … │\n",
- "│ (BatchNormalizatio… │ │ │ │\n",
+ "│ B2.D1.DW.B1.BN │ (None , 1 , 256 , │ 64 │ B2.D1.DW.B1.CN[0 … │\n",
+ "│ (BatchNormalizatio… │ 16 ) │ │ │\n",
"├─────────────────────┼───────────────────┼────────────┼───────────────────┤\n",
- "│ B2.D1.DW.ACT │ (None , 1 , 256 , 8 ) │ 0 │ B2.D1.DW.B1.BN[0 … │\n",
- "│ (Activation ) │ │ │ │\n",
+ "│ B2.D1.DW.ACT │ (None , 1 , 256 , │ 0 │ B2.D1.DW.B1.BN[0 … │\n",
+ "│ (Activation ) │ 16 ) │ │ │\n",
"├─────────────────────┼───────────────────┼────────────┼───────────────────┤\n",
- "│ B2.D1.SE.pool │ (None , 1 , 1 , 8 ) │ 0 │ B2.D1.DW.ACT[0 ][… │\n",
+ "│ B2.D1.SE.pool │ (None , 1 , 1 , 16 ) │ 0 │ B2.D1.DW.ACT[0 ][… │\n",
"│ (GlobalAveragePool… │ │ │ │\n",
"├─────────────────────┼───────────────────┼────────────┼───────────────────┤\n",
- "│ B2.D1.SE.sq.conv │ (None , 1 , 1 , 4 ) │ 36 │ B2.D1.SE.pool[0 ]… │\n",
+ "│ B2.D1.SE.sq.conv │ (None , 1 , 1 , 8 ) │ 136 │ B2.D1.SE.pool[0 ]… │\n",
"│ (Conv2D ) │ │ │ │\n",
"├─────────────────────┼───────────────────┼────────────┼───────────────────┤\n",
- "│ B2.D1.SE.sq.act │ (None , 1 , 1 , 4 ) │ 0 │ B2.D1.SE.sq.conv… │\n",
+ "│ B2.D1.SE.sq.act │ (None , 1 , 1 , 8 ) │ 0 │ B2.D1.SE.sq.conv… │\n",
"│ (Activation ) │ │ │ │\n",
"├─────────────────────┼───────────────────┼────────────┼───────────────────┤\n",
- "│ B2.D1.SE.ex.conv │ (None , 1 , 1 , 8 ) │ 40 │ B2.D1.SE.sq.act[… │\n",
+ "│ B2.D1.SE.ex.conv │ (None , 1 , 1 , 16 ) │ 144 │ B2.D1.SE.sq.act[… │\n",
"│ (Conv2D ) │ │ │ │\n",
"├─────────────────────┼───────────────────┼────────────┼───────────────────┤\n",
- "│ B2.D1.SE.ex.act │ (None , 1 , 1 , 8 ) │ 0 │ B2.D1.SE.ex.conv… │\n",
+ "│ B2.D1.SE.ex.act │ (None , 1 , 1 , 16 ) │ 0 │ B2.D1.SE.ex.conv… │\n",
"│ (Activation ) │ │ │ │\n",
"├─────────────────────┼───────────────────┼────────────┼───────────────────┤\n",
- "│ multiply (Multiply ) │ (None , 1 , 256 , 8 ) │ 0 │ B2.D1.DW.ACT[0 ][… │\n",
- "│ │ │ │ B2.D1.SE.ex.act[… │\n",
+ "│ multiply (Multiply ) │ (None , 1 , 256 , │ 0 │ B2.D1.DW.ACT[0 ][… │\n",
+ "│ │ 16 ) │ │ B2.D1.SE.ex.act[… │\n",
"├─────────────────────┼───────────────────┼────────────┼───────────────────┤\n",
- "│ B2.D1.PW.B1.CN │ (None , 1 , 256 , │ 128 │ multiply[0 ][0 ] │\n",
- "│ (Conv2D ) │ 16 ) │ │ │\n",
+ "│ B2.D1.PW.B1.CN │ (None , 1 , 256 , │ 384 │ multiply[0 ][0 ] │\n",
+ "│ (Conv2D ) │ 24 ) │ │ │\n",
"├─────────────────────┼───────────────────┼────────────┼───────────────────┤\n",
- "│ B2.D1.PW.B1.BN │ (None , 1 , 256 , │ 64 │ B2.D1.PW.B1.CN[0 … │\n",
- "│ (BatchNormalizatio… │ 16 ) │ │ │\n",
+ "│ B2.D1.PW.B1.BN │ (None , 1 , 256 , │ 96 │ B2.D1.PW.B1.CN[0 … │\n",
+ "│ (BatchNormalizatio… │ 24 ) │ │ │\n",
"├─────────────────────┼───────────────────┼────────────┼───────────────────┤\n",
"│ B2.D1.PW.ACT │ (None , 1 , 256 , │ 0 │ B2.D1.PW.B1.BN[0 … │\n",
- "│ (Activation ) │ 16 ) │ │ │\n",
+ "│ (Activation ) │ 24 ) │ │ │\n",
"├─────────────────────┼───────────────────┼────────────┼───────────────────┤\n",
- "│ B3.D1.DW.B1.CN │ (None , 1 , 256 , │ 112 │ B2.D1.PW.ACT[0 ][… │\n",
- "│ (DepthwiseConv2D ) │ 16 ) │ │ │\n",
+ "│ B3.D1.DW.B1.CN │ (None , 1 , 256 , │ 168 │ B2.D1.PW.ACT[0 ][… │\n",
+ "│ (DepthwiseConv2D ) │ 24 ) │ │ │\n",
"└─────────────────────┴───────────────────┴────────────┴───────────────────┘\n",
"\n"
],
@@ -391,53 +663,53 @@
"│ B1.D1.DW.ACT │ (\u001b[38;5;45mNone\u001b[0m, \u001b[38;5;34m1\u001b[0m, \u001b[38;5;34m256\u001b[0m, \u001b[38;5;34m1\u001b[0m) │ \u001b[38;5;34m0\u001b[0m │ B1.D1.DW.B1.BN[\u001b[38;5;34m0\u001b[0m… │\n",
"│ (\u001b[38;5;33mActivation\u001b[0m) │ │ │ │\n",
"├─────────────────────┼───────────────────┼────────────┼───────────────────┤\n",
- "│ B1.D1.PW.B1.CN │ (\u001b[38;5;45mNone\u001b[0m, \u001b[38;5;34m1\u001b[0m, \u001b[38;5;34m256\u001b[0m, \u001b[38;5;34m8\u001b[0m) │ \u001b[38;5;34m8\u001b[0m │ B1.D1.DW.ACT[\u001b[38;5;34m0\u001b[0m][\u001b[38;5;34m…\u001b[0m │\n",
- "│ (\u001b[38;5;33mConv2D\u001b[0m) │ │ │ │\n",
+ "│ B1.D1.PW.B1.CN │ (\u001b[38;5;45mNone\u001b[0m, \u001b[38;5;34m1\u001b[0m, \u001b[38;5;34m256\u001b[0m, │ \u001b[38;5;34m16\u001b[0m │ B1.D1.DW.ACT[\u001b[38;5;34m0\u001b[0m][\u001b[38;5;34m…\u001b[0m │\n",
+ "│ (\u001b[38;5;33mConv2D\u001b[0m) │ \u001b[38;5;34m16\u001b[0m) │ │ │\n",
"├─────────────────────┼───────────────────┼────────────┼───────────────────┤\n",
- "│ B1.D1.PW.B1.BN │ (\u001b[38;5;45mNone\u001b[0m, \u001b[38;5;34m1\u001b[0m, \u001b[38;5;34m256\u001b[0m, \u001b[38;5;34m8\u001b[0m) │ \u001b[38;5;34m32\u001b[0m │ B1.D1.PW.B1.CN[\u001b[38;5;34m0\u001b[0m… │\n",
- "│ (\u001b[38;5;33mBatchNormalizatio…\u001b[0m │ │ │ │\n",
+ "│ B1.D1.PW.B1.BN │ (\u001b[38;5;45mNone\u001b[0m, \u001b[38;5;34m1\u001b[0m, \u001b[38;5;34m256\u001b[0m, │ \u001b[38;5;34m64\u001b[0m │ B1.D1.PW.B1.CN[\u001b[38;5;34m0\u001b[0m… │\n",
+ "│ (\u001b[38;5;33mBatchNormalizatio…\u001b[0m │ \u001b[38;5;34m16\u001b[0m) │ │ │\n",
"├─────────────────────┼───────────────────┼────────────┼───────────────────┤\n",
- "│ B1.D1.PW.ACT │ (\u001b[38;5;45mNone\u001b[0m, \u001b[38;5;34m1\u001b[0m, \u001b[38;5;34m256\u001b[0m, \u001b[38;5;34m8\u001b[0m) │ \u001b[38;5;34m0\u001b[0m │ B1.D1.PW.B1.BN[\u001b[38;5;34m0\u001b[0m… │\n",
- "│ (\u001b[38;5;33mActivation\u001b[0m) │ │ │ │\n",
+ "│ B1.D1.PW.ACT │ (\u001b[38;5;45mNone\u001b[0m, \u001b[38;5;34m1\u001b[0m, \u001b[38;5;34m256\u001b[0m, │ \u001b[38;5;34m0\u001b[0m │ B1.D1.PW.B1.BN[\u001b[38;5;34m0\u001b[0m… │\n",
+ "│ (\u001b[38;5;33mActivation\u001b[0m) │ \u001b[38;5;34m16\u001b[0m) │ │ │\n",
"├─────────────────────┼───────────────────┼────────────┼───────────────────┤\n",
- "│ B2.D1.DW.B1.CN │ (\u001b[38;5;45mNone\u001b[0m, \u001b[38;5;34m1\u001b[0m, \u001b[38;5;34m256\u001b[0m, \u001b[38;5;34m8\u001b[0m) │ \u001b[38;5;34m56\u001b[0m │ B1.D1.PW.ACT[\u001b[38;5;34m0\u001b[0m][\u001b[38;5;34m…\u001b[0m │\n",
- "│ (\u001b[38;5;33mDepthwiseConv2D\u001b[0m) │ │ │ │\n",
+ "│ B2.D1.DW.B1.CN │ (\u001b[38;5;45mNone\u001b[0m, \u001b[38;5;34m1\u001b[0m, \u001b[38;5;34m256\u001b[0m, │ \u001b[38;5;34m112\u001b[0m │ B1.D1.PW.ACT[\u001b[38;5;34m0\u001b[0m][\u001b[38;5;34m…\u001b[0m │\n",
+ "│ (\u001b[38;5;33mDepthwiseConv2D\u001b[0m) │ \u001b[38;5;34m16\u001b[0m) │ │ │\n",
"├─────────────────────┼───────────────────┼────────────┼───────────────────┤\n",
- "│ B2.D1.DW.B1.BN │ (\u001b[38;5;45mNone\u001b[0m, \u001b[38;5;34m1\u001b[0m, \u001b[38;5;34m256\u001b[0m, \u001b[38;5;34m8\u001b[0m) │ \u001b[38;5;34m32\u001b[0m │ B2.D1.DW.B1.CN[\u001b[38;5;34m0\u001b[0m… │\n",
- "│ (\u001b[38;5;33mBatchNormalizatio…\u001b[0m │ │ │ │\n",
+ "│ B2.D1.DW.B1.BN │ (\u001b[38;5;45mNone\u001b[0m, \u001b[38;5;34m1\u001b[0m, \u001b[38;5;34m256\u001b[0m, │ \u001b[38;5;34m64\u001b[0m │ B2.D1.DW.B1.CN[\u001b[38;5;34m0\u001b[0m… │\n",
+ "│ (\u001b[38;5;33mBatchNormalizatio…\u001b[0m │ \u001b[38;5;34m16\u001b[0m) │ │ │\n",
"├─────────────────────┼───────────────────┼────────────┼───────────────────┤\n",
- "│ B2.D1.DW.ACT │ (\u001b[38;5;45mNone\u001b[0m, \u001b[38;5;34m1\u001b[0m, \u001b[38;5;34m256\u001b[0m, \u001b[38;5;34m8\u001b[0m) │ \u001b[38;5;34m0\u001b[0m │ B2.D1.DW.B1.BN[\u001b[38;5;34m0\u001b[0m… │\n",
- "│ (\u001b[38;5;33mActivation\u001b[0m) │ │ │ │\n",
+ "│ B2.D1.DW.ACT │ (\u001b[38;5;45mNone\u001b[0m, \u001b[38;5;34m1\u001b[0m, \u001b[38;5;34m256\u001b[0m, │ \u001b[38;5;34m0\u001b[0m │ B2.D1.DW.B1.BN[\u001b[38;5;34m0\u001b[0m… │\n",
+ "│ (\u001b[38;5;33mActivation\u001b[0m) │ \u001b[38;5;34m16\u001b[0m) │ │ │\n",
"├─────────────────────┼───────────────────┼────────────┼───────────────────┤\n",
- "│ B2.D1.SE.pool │ (\u001b[38;5;45mNone\u001b[0m, \u001b[38;5;34m1\u001b[0m, \u001b[38;5;34m1\u001b[0m, \u001b[38;5;34m8\u001b[0m) │ \u001b[38;5;34m0\u001b[0m │ B2.D1.DW.ACT[\u001b[38;5;34m0\u001b[0m][\u001b[38;5;34m…\u001b[0m │\n",
+ "│ B2.D1.SE.pool │ (\u001b[38;5;45mNone\u001b[0m, \u001b[38;5;34m1\u001b[0m, \u001b[38;5;34m1\u001b[0m, \u001b[38;5;34m16\u001b[0m) │ \u001b[38;5;34m0\u001b[0m │ B2.D1.DW.ACT[\u001b[38;5;34m0\u001b[0m][\u001b[38;5;34m…\u001b[0m │\n",
"│ (\u001b[38;5;33mGlobalAveragePool…\u001b[0m │ │ │ │\n",
"├─────────────────────┼───────────────────┼────────────┼───────────────────┤\n",
- "│ B2.D1.SE.sq.conv │ (\u001b[38;5;45mNone\u001b[0m, \u001b[38;5;34m1\u001b[0m, \u001b[38;5;34m1\u001b[0m, \u001b[38;5;34m4\u001b[0m) │ \u001b[38;5;34m36\u001b[0m │ B2.D1.SE.pool[\u001b[38;5;34m0\u001b[0m]… │\n",
+ "│ B2.D1.SE.sq.conv │ (\u001b[38;5;45mNone\u001b[0m, \u001b[38;5;34m1\u001b[0m, \u001b[38;5;34m1\u001b[0m, \u001b[38;5;34m8\u001b[0m) │ \u001b[38;5;34m136\u001b[0m │ B2.D1.SE.pool[\u001b[38;5;34m0\u001b[0m]… │\n",
"│ (\u001b[38;5;33mConv2D\u001b[0m) │ │ │ │\n",
"├─────────────────────┼───────────────────┼────────────┼───────────────────┤\n",
- "│ B2.D1.SE.sq.act │ (\u001b[38;5;45mNone\u001b[0m, \u001b[38;5;34m1\u001b[0m, \u001b[38;5;34m1\u001b[0m, \u001b[38;5;34m4\u001b[0m) │ \u001b[38;5;34m0\u001b[0m │ B2.D1.SE.sq.conv… │\n",
+ "│ B2.D1.SE.sq.act │ (\u001b[38;5;45mNone\u001b[0m, \u001b[38;5;34m1\u001b[0m, \u001b[38;5;34m1\u001b[0m, \u001b[38;5;34m8\u001b[0m) │ \u001b[38;5;34m0\u001b[0m │ B2.D1.SE.sq.conv… │\n",
"│ (\u001b[38;5;33mActivation\u001b[0m) │ │ │ │\n",
"├─────────────────────┼───────────────────┼────────────┼───────────────────┤\n",
- "│ B2.D1.SE.ex.conv │ (\u001b[38;5;45mNone\u001b[0m, \u001b[38;5;34m1\u001b[0m, \u001b[38;5;34m1\u001b[0m, \u001b[38;5;34m8\u001b[0m) │ \u001b[38;5;34m40\u001b[0m │ B2.D1.SE.sq.act[\u001b[38;5;34m…\u001b[0m │\n",
+ "│ B2.D1.SE.ex.conv │ (\u001b[38;5;45mNone\u001b[0m, \u001b[38;5;34m1\u001b[0m, \u001b[38;5;34m1\u001b[0m, \u001b[38;5;34m16\u001b[0m) │ \u001b[38;5;34m144\u001b[0m │ B2.D1.SE.sq.act[\u001b[38;5;34m…\u001b[0m │\n",
"│ (\u001b[38;5;33mConv2D\u001b[0m) │ │ │ │\n",
"├─────────────────────┼───────────────────┼────────────┼───────────────────┤\n",
- "│ B2.D1.SE.ex.act │ (\u001b[38;5;45mNone\u001b[0m, \u001b[38;5;34m1\u001b[0m, \u001b[38;5;34m1\u001b[0m, \u001b[38;5;34m8\u001b[0m) │ \u001b[38;5;34m0\u001b[0m │ B2.D1.SE.ex.conv… │\n",
+ "│ B2.D1.SE.ex.act │ (\u001b[38;5;45mNone\u001b[0m, \u001b[38;5;34m1\u001b[0m, \u001b[38;5;34m1\u001b[0m, \u001b[38;5;34m16\u001b[0m) │ \u001b[38;5;34m0\u001b[0m │ B2.D1.SE.ex.conv… │\n",
"│ (\u001b[38;5;33mActivation\u001b[0m) │ │ │ │\n",
"├─────────────────────┼───────────────────┼────────────┼───────────────────┤\n",
- "│ multiply (\u001b[38;5;33mMultiply\u001b[0m) │ (\u001b[38;5;45mNone\u001b[0m, \u001b[38;5;34m1\u001b[0m, \u001b[38;5;34m256\u001b[0m, \u001b[38;5;34m8\u001b[0m) │ \u001b[38;5;34m0\u001b[0m │ B2.D1.DW.ACT[\u001b[38;5;34m0\u001b[0m][\u001b[38;5;34m…\u001b[0m │\n",
- "│ │ │ │ B2.D1.SE.ex.act[\u001b[38;5;34m…\u001b[0m │\n",
+ "│ multiply (\u001b[38;5;33mMultiply\u001b[0m) │ (\u001b[38;5;45mNone\u001b[0m, \u001b[38;5;34m1\u001b[0m, \u001b[38;5;34m256\u001b[0m, │ \u001b[38;5;34m0\u001b[0m │ B2.D1.DW.ACT[\u001b[38;5;34m0\u001b[0m][\u001b[38;5;34m…\u001b[0m │\n",
+ "│ │ \u001b[38;5;34m16\u001b[0m) │ │ B2.D1.SE.ex.act[\u001b[38;5;34m…\u001b[0m │\n",
"├─────────────────────┼───────────────────┼────────────┼───────────────────┤\n",
- "│ B2.D1.PW.B1.CN │ (\u001b[38;5;45mNone\u001b[0m, \u001b[38;5;34m1\u001b[0m, \u001b[38;5;34m256\u001b[0m, │ \u001b[38;5;34m128\u001b[0m │ multiply[\u001b[38;5;34m0\u001b[0m][\u001b[38;5;34m0\u001b[0m] │\n",
- "│ (\u001b[38;5;33mConv2D\u001b[0m) │ \u001b[38;5;34m16\u001b[0m) │ │ │\n",
+ "│ B2.D1.PW.B1.CN │ (\u001b[38;5;45mNone\u001b[0m, \u001b[38;5;34m1\u001b[0m, \u001b[38;5;34m256\u001b[0m, │ \u001b[38;5;34m384\u001b[0m │ multiply[\u001b[38;5;34m0\u001b[0m][\u001b[38;5;34m0\u001b[0m] │\n",
+ "│ (\u001b[38;5;33mConv2D\u001b[0m) │ \u001b[38;5;34m24\u001b[0m) │ │ │\n",
"├─────────────────────┼───────────────────┼────────────┼───────────────────┤\n",
- "│ B2.D1.PW.B1.BN │ (\u001b[38;5;45mNone\u001b[0m, \u001b[38;5;34m1\u001b[0m, \u001b[38;5;34m256\u001b[0m, │ \u001b[38;5;34m64\u001b[0m │ B2.D1.PW.B1.CN[\u001b[38;5;34m0\u001b[0m… │\n",
- "│ (\u001b[38;5;33mBatchNormalizatio…\u001b[0m │ \u001b[38;5;34m16\u001b[0m) │ │ │\n",
+ "│ B2.D1.PW.B1.BN │ (\u001b[38;5;45mNone\u001b[0m, \u001b[38;5;34m1\u001b[0m, \u001b[38;5;34m256\u001b[0m, │ \u001b[38;5;34m96\u001b[0m │ B2.D1.PW.B1.CN[\u001b[38;5;34m0\u001b[0m… │\n",
+ "│ (\u001b[38;5;33mBatchNormalizatio…\u001b[0m │ \u001b[38;5;34m24\u001b[0m) │ │ │\n",
"├─────────────────────┼───────────────────┼────────────┼───────────────────┤\n",
"│ B2.D1.PW.ACT │ (\u001b[38;5;45mNone\u001b[0m, \u001b[38;5;34m1\u001b[0m, \u001b[38;5;34m256\u001b[0m, │ \u001b[38;5;34m0\u001b[0m │ B2.D1.PW.B1.BN[\u001b[38;5;34m0\u001b[0m… │\n",
- "│ (\u001b[38;5;33mActivation\u001b[0m) │ \u001b[38;5;34m16\u001b[0m) │ │ │\n",
+ "│ (\u001b[38;5;33mActivation\u001b[0m) │ \u001b[38;5;34m24\u001b[0m) │ │ │\n",
"├─────────────────────┼───────────────────┼────────────┼───────────────────┤\n",
- "│ B3.D1.DW.B1.CN │ (\u001b[38;5;45mNone\u001b[0m, \u001b[38;5;34m1\u001b[0m, \u001b[38;5;34m256\u001b[0m, │ \u001b[38;5;34m112\u001b[0m │ B2.D1.PW.ACT[\u001b[38;5;34m0\u001b[0m][\u001b[38;5;34m…\u001b[0m │\n",
- "│ (\u001b[38;5;33mDepthwiseConv2D\u001b[0m) │ \u001b[38;5;34m16\u001b[0m) │ │ │\n",
+ "│ B3.D1.DW.B1.CN │ (\u001b[38;5;45mNone\u001b[0m, \u001b[38;5;34m1\u001b[0m, \u001b[38;5;34m256\u001b[0m, │ \u001b[38;5;34m168\u001b[0m │ B2.D1.PW.ACT[\u001b[38;5;34m0\u001b[0m][\u001b[38;5;34m…\u001b[0m │\n",
+ "│ (\u001b[38;5;33mDepthwiseConv2D\u001b[0m) │ \u001b[38;5;34m24\u001b[0m) │ │ │\n",
"└─────────────────────┴───────────────────┴────────────┴───────────────────┘\n"
]
},
@@ -447,11 +719,11 @@
{
"data": {
"text/html": [
- " Total params: 3,351 (13.09 KB)\n",
+ " Total params: 10,223 (39.93 KB)\n",
" \n"
],
"text/plain": [
- "\u001b[1m Total params: \u001b[0m\u001b[38;5;34m3,351\u001b[0m (13.09 KB)\n"
+ "\u001b[1m Total params: \u001b[0m\u001b[38;5;34m10,223\u001b[0m (39.93 KB)\n"
]
},
"metadata": {},
@@ -460,11 +732,11 @@
{
"data": {
"text/html": [
- " Trainable params: 3,091 (12.07 KB)\n",
+ " Trainable params: 9,675 (37.79 KB)\n",
" \n"
],
"text/plain": [
- "\u001b[1m Trainable params: \u001b[0m\u001b[38;5;34m3,091\u001b[0m (12.07 KB)\n"
+ "\u001b[1m Trainable params: \u001b[0m\u001b[38;5;34m9,675\u001b[0m (37.79 KB)\n"
]
},
"metadata": {},
@@ -473,11 +745,11 @@
{
"data": {
"text/html": [
- " Non-trainable params: 260 (1.02 KB)\n",
+ " Non-trainable params: 548 (2.14 KB)\n",
" \n"
],
"text/plain": [
- "\u001b[1m Non-trainable params: \u001b[0m\u001b[38;5;34m260\u001b[0m (1.02 KB)\n"
+ "\u001b[1m Non-trainable params: \u001b[0m\u001b[38;5;34m548\u001b[0m (2.14 KB)\n"
]
},
"metadata": {},
@@ -490,85 +762,76 @@
" params=architecture[\"params\"],\n",
" num_classes=1\n",
")\n",
- "model.summary(layer_range=('inputs', 'B3.D1.DW.B1.CN'))\n",
- "#keras.utils.plot_model(model, show_shapes=True)"
+ "model.summary(layer_range=('inputs', 'B3.D1.DW.B1.CN'))"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
- "## Preprocess pipeline\n",
- "\n",
- "We will preprocess the ECG signals by applying the following steps:\n",
- "* Apply Z-score normalization w/ epsilon to avoid division by zero\n",
- "\n",
- "The task accepts a list of preprocessing functions that will be applied to the input data. \n",
+ "## Compile the model\n",
"\n",
- "__NOTE:__ We dont apply any filtering as the model is expected to learn the filtering mechanism."
+ "We will compile the model using Adam optimizer with cosine learning rate scheduler and mean squared error loss function. We will also attach metrics and callbacks to monitor the training process.\n"
]
},
{
"cell_type": "code",
- "execution_count": 8,
+ "execution_count": 14,
"metadata": {},
"outputs": [],
"source": [
- "preprocesses = [\n",
- " dict(name=\"znorm\", params=dict(eps=0.01, axis=None))\n",
- "]"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "## Augmentation pipeline\n",
+ "t_mul = 1\n",
+ "lr_cycles = 1\n",
+ "first_steps = (steps_per_epoch * epochs) / (np.power(lr_cycles, t_mul) - t_mul + 1)\n",
+ "scheduler = keras.optimizers.schedules.CosineDecayRestarts(\n",
+ " initial_learning_rate=learning_rate,\n",
+ " first_decay_steps=np.ceil(first_steps),\n",
+ " t_mul=t_mul,\n",
+ " m_mul=0.5,\n",
+ ")\n",
+ "optimizer = keras.optimizers.Adam(scheduler)\n",
+ "loss = keras.losses.MeanSquaredError()\n",
"\n",
- "We will apply the following augmentations to the ECG signals:\n",
- "* Baseline wander: Simulate baseline wander by adding a random frequency sinusoidal signal to the ECG signal\n",
- "* Powerline noise: Simulate powerline noise by adding a 50 Hz sinusoidal signal to the ECG signal\n",
- "* Burst noise: Simulate burst noise by randomly injecting burst of high frequency noise to the ECG signal\n",
- "* Noise sources: Apply several noises at given frequencies to the ECG signal\n",
- "* Lead noise: Simulate lead noise by adding a random frequency sinusoidal signal to the ECG signal\n",
- "* NSTDB: Add real noise captured from NSTDB dataset to the ECG signal. \n"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 9,
- "metadata": {},
- "outputs": [],
- "source": [
- "augmentations = [\n",
- " hk.AugmentationParams(name=\"baseline_wander\", params=dict(amplitude=[0.0, 0.5], frequency=[0.5, 1.5])),\n",
- " hk.AugmentationParams(name=\"powerline_noise\", params=dict(amplitude=[0.05, 0.15], frequency=[45, 50])),\n",
- " hk.AugmentationParams(name=\"burst_noise\", params=dict(burst_number=[0, 4], amplitude=[0.05, 0.1], frequency=[20, 49])),\n",
- " hk.AugmentationParams(name=\"noise_sources\", params=dict(num_sources=[1, 2], amplitude=[0.05, 0.1], frequency=[10, 40])),\n",
- " hk.AugmentationParams(name=\"lead_noise\", params=dict(scale=[0.05, 0.1])),\n",
- " hk.AugmentationParams(name=\"nstdb\", params=dict(noise_level=[0.1, 0.3]))\n",
- "]"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "### Visualize the augmentations\n",
+ "metrics = [\n",
+ " keras.metrics.MeanAbsoluteError(name=\"mae\"),\n",
+ " keras.metrics.MeanSquaredError(name=\"mse\"),\n",
+ " keras.metrics.CosineSimilarity(name=\"cos\"),\n",
+ " nse.metrics.Snr(name=\"snr\"),\n",
+ "]\n",
"\n",
- "Taking the existing synthetic ECG signal, let's look at the effects of the augmentations on the signal."
+ "model_callbacks = [\n",
+ " keras.callbacks.EarlyStopping(\n",
+ " monitor=f\"val_{val_metric}\",\n",
+ " patience=max(int(0.25 * epochs), 1),\n",
+ " mode=val_mode,\n",
+ " restore_best_weights=True,\n",
+ " verbose=min(verbose - 1, 1),\n",
+ " ),\n",
+ " keras.callbacks.ModelCheckpoint(\n",
+ " filepath=str(model_file),\n",
+ " monitor=f\"val_{val_metric}\",\n",
+ " save_best_only=True,\n",
+ " save_weights_only=False,\n",
+ " mode=val_mode,\n",
+ " verbose=min(verbose - 1, 1),\n",
+ " ),\n",
+ " keras.callbacks.CSVLogger(job_dir / \"history.csv\"),\n",
+ "]"
]
},
{
"cell_type": "code",
- "execution_count": 10,
+ "execution_count": 15,
"metadata": {},
"outputs": [
{
"data": {
- "image/png": "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",
+ "text/html": [
+ "INFO Model requires 3.02 MFLOPS 1319647386.py : 3 \n",
+ " \n"
+ ],
"text/plain": [
- ""
+ "\u001b[34mINFO \u001b[0m Model requires \u001b[1;36m3.02\u001b[0m MFLOPS \u001b]8;id=119005;file:///tmp/ipykernel_1619872/1319647386.py\u001b\\\u001b[2m1319647386.py\u001b[0m\u001b]8;;\u001b\\\u001b[2m:\u001b[0m\u001b]8;id=942841;file:///tmp/ipykernel_1619872/1319647386.py#3\u001b\\\u001b[2m3\u001b[0m\u001b]8;;\u001b\\\n"
]
},
"metadata": {},
@@ -576,215 +839,268 @@
}
],
"source": [
- "ecg_noise = hk.datasets.augment_pipeline(ecg, augmentations=augmentations, sample_rate=sampling_rate)\n",
- "ts = np.arange(0, len(ecg)) / sampling_rate\n",
- "fig, ax = plt.subplots(1, 1, figsize=(10, 5))\n",
- "plt.plot(ts, ecg_noise, color=primary_color, lw=3)\n",
- "plt.title(\"Synthetic ECG w/ Noise\")\n",
- "ax.set_xlabel(\"Time (s)\")\n",
- "ax.set_ylabel(\"Amplitude\")\n",
- "plt.show()\n"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "## Load denoise task \n",
- "\n",
- "HeartKit provides a __TaskFactory__ that includes a number ready-to-use tasks. Each task provides methods for training, evaluating, exporting, and demoing. We will grab the __denoise__ task and configure it for our use case."
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 11,
- "metadata": {},
- "outputs": [],
- "source": [
- "task = hk.TaskFactory.get(\"denoise\")"
+ "model.compile(optimizer=optimizer, loss=loss, metrics=metrics)\n",
+ "flops = nse.metrics.flops.get_flops(model, batch_size=1, fpath=os.devnull)\n",
+ "logger.info(f\"Model requires {flops/1e6:0.2f} MFLOPS\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
- "## Train the model\n",
- "\n",
- "The task's __train__ method accepts a high-level configuration that includes dataset, model, classes, preprocessing, and training parameters. We will provide the following configuration to train the model."
+ "## Train the model"
]
},
{
"cell_type": "code",
- "execution_count": 12,
- "metadata": {},
- "outputs": [],
- "source": [
- "train_params = hk.HKTrainParams(\n",
- " job_dir=job_dir, # Directory to store all output artifacts\n",
- " datasets=datasets, # Datasets to train on\n",
- " sampling_rate=sampling_rate, # Target sampling rate\n",
- " frame_size=frame_size, # Target frame size\n",
- " # Training parameters\n",
- " samples_per_patient=samples_per_patient, # Samples per train patient\n",
- " val_samples_per_patient=samples_per_patient, # Samples per val patient\n",
- " val_patients=val_percentage, # Percentage of patients used for validation\n",
- " val_file=val_file, # Validation file (cached)\n",
- " batch_size=batch_size, # Batch size\n",
- " buffer_size=buffer_size, # Buffer size\n",
- " epochs=epochs, # Number of epochs to train\n",
- " steps_per_epoch=steps_per_epoch, # Steps per epoch\n",
- " val_metric=\"loss\", # Metric to monitor for early stopping\n",
- " lr_rate=learning_rate, # Learning rate\n",
- " lr_cycles=1, # Number of learning rate cycles for cosine decay\n",
- " class_weights=\"balanced\", # Utilize class weights to balance training\n",
- " preprocesses=preprocesses, # Preprocessing pipeline\n",
- " augmentations=augmentations, # Augmentation pipeline\n",
- " architecture=architecture, # Model architecture\n",
- " model_file=model_file, # File to save model\n",
- " verbose=verbose # Verbosity level\n",
- ")"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 13,
+ "execution_count": 16,
"metadata": {},
"outputs": [
{
- "data": {
- "text/html": [
- " \n"
- ],
- "text/plain": []
- },
- "metadata": {},
- "output_type": "display_data"
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Epoch 1/100\n"
+ ]
+ },
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "WARNING: All log messages before absl::InitializeLog() is called are written to STDERR\n",
+ "I0000 00:00:1723573267.969050 1620307 service.cc:146] XLA service 0x797c7800b300 initialized for platform CUDA (this does not guarantee that XLA will be used). Devices:\n",
+ "I0000 00:00:1723573267.969070 1620307 service.cc:154] StreamExecutor device (0): NVIDIA GeForce RTX 4090, Compute Capability 8.9\n"
+ ]
},
{
"name": "stdout",
"output_type": "stream",
"text": [
- "Epoch 1/50\n"
+ "\u001b[1m13/50\u001b[0m \u001b[32m━━━━━\u001b[0m\u001b[37m━━━━━━━━━━━━━━━\u001b[0m \u001b[1m0s\u001b[0m 13ms/step - cos: 0.1119 - loss: 1.8925 - mae: 0.8850 - mse: 1.5532 - snr: -4.3028"
]
},
{
"name": "stderr",
"output_type": "stream",
"text": [
- "WARNING: All log messages before absl::InitializeLog() is called are written to STDERR\n",
- "I0000 00:00:1721248273.976760 773045 service.cc:146] XLA service 0x7efdf0024350 initialized for platform CUDA (this does not guarantee that XLA will be used). Devices:\n",
- "I0000 00:00:1721248273.976801 773045 service.cc:154] StreamExecutor device (0): NVIDIA GeForce RTX 4090, Compute Capability 8.9\n",
- "I0000 00:00:1721248280.084769 773045 device_compiler.h:188] Compiled cluster using XLA! This line is logged at most once for the lifetime of the process.\n"
+ "I0000 00:00:1723573276.273866 1620307 device_compiler.h:188] Compiled cluster using XLA! This line is logged at most once for the lifetime of the process.\n"
]
},
{
"name": "stdout",
"output_type": "stream",
"text": [
- "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m172s\u001b[0m 1s/step - cosine: 0.0173 - loss: 1.2401 - mae: 0.7030 - mse: 1.0883 - val_cosine: 0.2671 - val_loss: 1.0405 - val_mae: 0.4550 - val_mse: 0.8911\n",
- "Epoch 2/50\n",
- "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m57s\u001b[0m 1s/step - cosine: 0.3424 - loss: 0.5309 - mae: 0.4269 - mse: 0.3830 - val_cosine: 0.2670 - val_loss: 1.0345 - val_mae: 0.4534 - val_mse: 0.8915\n",
- "Epoch 3/50\n",
- "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m46s\u001b[0m 940ms/step - cosine: 0.4254 - loss: 0.4207 - mae: 0.3632 - mse: 0.2794 - val_cosine: 0.2615 - val_loss: 1.0199 - val_mae: 0.4532 - val_mse: 0.8841\n",
- "Epoch 4/50\n",
- "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m38s\u001b[0m 769ms/step - cosine: 0.4711 - loss: 0.3707 - mae: 0.3319 - mse: 0.2368 - val_cosine: 0.2471 - val_loss: 0.9974 - val_mae: 0.4525 - val_mse: 0.8693\n",
- "Epoch 5/50\n",
- "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m52s\u001b[0m 1s/step - cosine: 0.4974 - loss: 0.3347 - mae: 0.3098 - mse: 0.2085 - val_cosine: 0.2387 - val_loss: 0.9550 - val_mae: 0.4517 - val_mse: 0.8345\n",
- "Epoch 6/50\n",
- "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m38s\u001b[0m 769ms/step - cosine: 0.5114 - loss: 0.3075 - mae: 0.2940 - mse: 0.1889 - val_cosine: 0.3069 - val_loss: 0.9090 - val_mae: 0.4500 - val_mse: 0.7960\n",
- "Epoch 7/50\n",
- "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m31s\u001b[0m 633ms/step - cosine: 0.5253 - loss: 0.2807 - mae: 0.2785 - mse: 0.1694 - val_cosine: 0.0917 - val_loss: 0.8503 - val_mae: 0.4423 - val_mse: 0.7444\n",
- "Epoch 8/50\n",
- "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m44s\u001b[0m 891ms/step - cosine: 0.5421 - loss: 0.2650 - mae: 0.2711 - mse: 0.1608 - val_cosine: 0.0542 - val_loss: 0.7833 - val_mae: 0.4354 - val_mse: 0.6842\n",
- "Epoch 9/50\n",
- "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m51s\u001b[0m 1s/step - cosine: 0.5578 - loss: 0.2446 - mae: 0.2583 - mse: 0.1470 - val_cosine: 0.1658 - val_loss: 0.6950 - val_mae: 0.4125 - val_mse: 0.6022\n",
- "Epoch 10/50\n",
- "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m51s\u001b[0m 1s/step - cosine: 0.5738 - loss: 0.2327 - mae: 0.2534 - mse: 0.1414 - val_cosine: 0.4603 - val_loss: 0.6101 - val_mae: 0.3853 - val_mse: 0.5233\n",
- "Epoch 11/50\n",
- "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m20s\u001b[0m 394ms/step - cosine: 0.5944 - loss: 0.2191 - mae: 0.2457 - mse: 0.1338 - val_cosine: 0.4658 - val_loss: 0.5266 - val_mae: 0.3602 - val_mse: 0.4454\n",
- "Epoch 12/50\n",
- "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m48s\u001b[0m 969ms/step - cosine: 0.6062 - loss: 0.2090 - mae: 0.2406 - mse: 0.1291 - val_cosine: 0.4720 - val_loss: 0.4508 - val_mae: 0.3403 - val_mse: 0.3747\n",
- "Epoch 13/50\n",
- "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m50s\u001b[0m 1s/step - cosine: 0.6215 - loss: 0.1931 - mae: 0.2297 - mse: 0.1182 - val_cosine: 0.4664 - val_loss: 0.3970 - val_mae: 0.3272 - val_mse: 0.3257\n",
- "Epoch 14/50\n",
- "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m50s\u001b[0m 1s/step - cosine: 0.6284 - loss: 0.1869 - mae: 0.2279 - mse: 0.1167 - val_cosine: 0.4988 - val_loss: 0.3484 - val_mae: 0.3078 - val_mse: 0.2815\n",
- "Epoch 15/50\n",
- "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m47s\u001b[0m 947ms/step - cosine: 0.6372 - loss: 0.1785 - mae: 0.2234 - mse: 0.1125 - val_cosine: 0.5839 - val_loss: 0.2929 - val_mae: 0.2845 - val_mse: 0.2300\n",
- "Epoch 16/50\n",
- "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m39s\u001b[0m 783ms/step - cosine: 0.6423 - loss: 0.1701 - mae: 0.2190 - mse: 0.1081 - val_cosine: 0.6217 - val_loss: 0.2372 - val_mae: 0.2521 - val_mse: 0.1779\n",
- "Epoch 17/50\n",
- "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m49s\u001b[0m 995ms/step - cosine: 0.6378 - loss: 0.1681 - mae: 0.2211 - mse: 0.1096 - val_cosine: 0.6379 - val_loss: 0.2117 - val_mae: 0.2411 - val_mse: 0.1558\n",
- "Epoch 18/50\n",
- "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m35s\u001b[0m 714ms/step - cosine: 0.6437 - loss: 0.1609 - mae: 0.2170 - mse: 0.1057 - val_cosine: 0.5878 - val_loss: 0.2094 - val_mae: 0.2419 - val_mse: 0.1566\n",
- "Epoch 19/50\n",
- "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m51s\u001b[0m 1s/step - cosine: 0.6424 - loss: 0.1598 - mae: 0.2182 - mse: 0.1076 - val_cosine: 0.6757 - val_loss: 0.1633 - val_mae: 0.2073 - val_mse: 0.1132\n",
- "Epoch 20/50\n",
- "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m51s\u001b[0m 1s/step - cosine: 0.6513 - loss: 0.1533 - mae: 0.2140 - mse: 0.1038 - val_cosine: 0.6543 - val_loss: 0.1584 - val_mae: 0.2099 - val_mse: 0.1108\n",
- "Epoch 21/50\n",
- "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m50s\u001b[0m 1s/step - cosine: 0.6545 - loss: 0.1494 - mae: 0.2132 - mse: 0.1024 - val_cosine: 0.6635 - val_loss: 0.1500 - val_mae: 0.2076 - val_mse: 0.1047\n",
- "Epoch 22/50\n",
- "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m40s\u001b[0m 812ms/step - cosine: 0.6556 - loss: 0.1448 - mae: 0.2096 - mse: 0.1001 - val_cosine: 0.6840 - val_loss: 0.1482 - val_mae: 0.2054 - val_mse: 0.1050\n",
- "Epoch 23/50\n",
- "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m52s\u001b[0m 1s/step - cosine: 0.6538 - loss: 0.1439 - mae: 0.2115 - mse: 0.1012 - val_cosine: 0.6901 - val_loss: 0.1333 - val_mae: 0.1954 - val_mse: 0.0920\n",
- "Epoch 24/50\n",
- "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m52s\u001b[0m 1s/step - cosine: 0.6564 - loss: 0.1427 - mae: 0.2119 - mse: 0.1019 - val_cosine: 0.7098 - val_loss: 0.1287 - val_mae: 0.1919 - val_mse: 0.0891\n",
- "Epoch 25/50\n",
- "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m50s\u001b[0m 1s/step - cosine: 0.6569 - loss: 0.1414 - mae: 0.2118 - mse: 0.1023 - val_cosine: 0.7069 - val_loss: 0.1211 - val_mae: 0.1882 - val_mse: 0.0831\n",
- "Epoch 26/50\n",
- "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m50s\u001b[0m 1s/step - cosine: 0.6541 - loss: 0.1399 - mae: 0.2121 - mse: 0.1023 - val_cosine: 0.7056 - val_loss: 0.1172 - val_mae: 0.1867 - val_mse: 0.0806\n",
- "Epoch 27/50\n",
- "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m51s\u001b[0m 1s/step - cosine: 0.6538 - loss: 0.1371 - mae: 0.2112 - mse: 0.1008 - val_cosine: 0.7265 - val_loss: 0.1085 - val_mae: 0.1767 - val_mse: 0.0732\n",
- "Epoch 28/50\n",
- "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m40s\u001b[0m 819ms/step - cosine: 0.6564 - loss: 0.1359 - mae: 0.2115 - mse: 0.1008 - val_cosine: 0.7088 - val_loss: 0.1057 - val_mae: 0.1768 - val_mse: 0.0715\n",
- "Epoch 29/50\n",
- "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m45s\u001b[0m 907ms/step - cosine: 0.6645 - loss: 0.1334 - mae: 0.2095 - mse: 0.0995 - val_cosine: 0.7276 - val_loss: 0.1037 - val_mae: 0.1747 - val_mse: 0.0706\n",
- "Epoch 30/50\n",
- "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m50s\u001b[0m 1s/step - cosine: 0.6587 - loss: 0.1303 - mae: 0.2072 - mse: 0.0973 - val_cosine: 0.7141 - val_loss: 0.0985 - val_mae: 0.1720 - val_mse: 0.0663\n",
- "Epoch 31/50\n",
- "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m50s\u001b[0m 1s/step - cosine: 0.6628 - loss: 0.1304 - mae: 0.2068 - mse: 0.0983 - val_cosine: 0.6357 - val_loss: 0.0993 - val_mae: 0.1810 - val_mse: 0.0678\n",
- "Epoch 32/50\n",
- "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m51s\u001b[0m 1s/step - cosine: 0.6616 - loss: 0.1273 - mae: 0.2055 - mse: 0.0961 - val_cosine: 0.7752 - val_loss: 0.0918 - val_mae: 0.1570 - val_mse: 0.0611\n",
- "Epoch 33/50\n",
- "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m51s\u001b[0m 1s/step - cosine: 0.6631 - loss: 0.1272 - mae: 0.2063 - mse: 0.0967 - val_cosine: 0.7410 - val_loss: 0.0888 - val_mae: 0.1594 - val_mse: 0.0588\n",
- "Epoch 34/50\n",
- "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m51s\u001b[0m 1s/step - cosine: 0.6680 - loss: 0.1235 - mae: 0.2024 - mse: 0.0936 - val_cosine: 0.7340 - val_loss: 0.0862 - val_mae: 0.1581 - val_mse: 0.0567\n",
- "Epoch 35/50\n",
- "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m51s\u001b[0m 1s/step - cosine: 0.6640 - loss: 0.1236 - mae: 0.2035 - mse: 0.0943 - val_cosine: 0.7692 - val_loss: 0.0800 - val_mae: 0.1452 - val_mse: 0.0511\n",
- "Epoch 36/50\n",
- "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m37s\u001b[0m 752ms/step - cosine: 0.6683 - loss: 0.1233 - mae: 0.2038 - mse: 0.0946 - val_cosine: 0.7843 - val_loss: 0.0762 - val_mae: 0.1377 - val_mse: 0.0478\n",
- "Epoch 37/50\n",
- "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m31s\u001b[0m 627ms/step - cosine: 0.6669 - loss: 0.1219 - mae: 0.2021 - mse: 0.0936 - val_cosine: 0.7967 - val_loss: 0.0747 - val_mae: 0.1344 - val_mse: 0.0467\n",
- "Epoch 38/50\n",
- "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m44s\u001b[0m 891ms/step - cosine: 0.6654 - loss: 0.1222 - mae: 0.2037 - mse: 0.0942 - val_cosine: 0.7940 - val_loss: 0.0722 - val_mae: 0.1310 - val_mse: 0.0445\n",
- "Epoch 39/50\n",
- "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m38s\u001b[0m 772ms/step - cosine: 0.6618 - loss: 0.1229 - mae: 0.2053 - mse: 0.0952 - val_cosine: 0.7925 - val_loss: 0.0700 - val_mae: 0.1273 - val_mse: 0.0426\n",
- "Epoch 40/50\n",
- "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m28s\u001b[0m 573ms/step - cosine: 0.6638 - loss: 0.1231 - mae: 0.2046 - mse: 0.0958 - val_cosine: 0.8037 - val_loss: 0.0681 - val_mae: 0.1230 - val_mse: 0.0409\n",
- "Epoch 41/50\n",
- "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m51s\u001b[0m 1s/step - cosine: 0.6652 - loss: 0.1219 - mae: 0.2052 - mse: 0.0948 - val_cosine: 0.8067 - val_loss: 0.0668 - val_mae: 0.1218 - val_mse: 0.0398\n",
- "Epoch 42/50\n",
- "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m43s\u001b[0m 874ms/step - cosine: 0.6717 - loss: 0.1201 - mae: 0.2013 - mse: 0.0931 - val_cosine: 0.8054 - val_loss: 0.0660 - val_mae: 0.1203 - val_mse: 0.0392\n",
- "Epoch 43/50\n",
- "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m44s\u001b[0m 889ms/step - cosine: 0.6696 - loss: 0.1207 - mae: 0.2025 - mse: 0.0939 - val_cosine: 0.8053 - val_loss: 0.0654 - val_mae: 0.1198 - val_mse: 0.0387\n",
- "Epoch 44/50\n",
- "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m29s\u001b[0m 582ms/step - cosine: 0.6657 - loss: 0.1212 - mae: 0.2038 - mse: 0.0945 - val_cosine: 0.8061 - val_loss: 0.0648 - val_mae: 0.1183 - val_mse: 0.0382\n",
- "Epoch 45/50\n",
- "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m32s\u001b[0m 642ms/step - cosine: 0.6696 - loss: 0.1199 - mae: 0.2021 - mse: 0.0933 - val_cosine: 0.8034 - val_loss: 0.0644 - val_mae: 0.1171 - val_mse: 0.0378\n",
- "Epoch 46/50\n",
- "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m27s\u001b[0m 552ms/step - cosine: 0.6688 - loss: 0.1219 - mae: 0.2040 - mse: 0.0953 - val_cosine: 0.8081 - val_loss: 0.0643 - val_mae: 0.1182 - val_mse: 0.0378\n",
- "Epoch 47/50\n",
- "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m26s\u001b[0m 519ms/step - cosine: 0.6690 - loss: 0.1195 - mae: 0.2024 - mse: 0.0930 - val_cosine: 0.8092 - val_loss: 0.0637 - val_mae: 0.1166 - val_mse: 0.0373\n",
- "Epoch 48/50\n",
- "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m37s\u001b[0m 754ms/step - cosine: 0.6697 - loss: 0.1203 - mae: 0.2032 - mse: 0.0938 - val_cosine: 0.8091 - val_loss: 0.0636 - val_mae: 0.1163 - val_mse: 0.0371\n",
- "Epoch 49/50\n",
- "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m42s\u001b[0m 851ms/step - cosine: 0.6629 - loss: 0.1242 - mae: 0.2057 - mse: 0.0978 - val_cosine: 0.8085 - val_loss: 0.0635 - val_mae: 0.1161 - val_mse: 0.0370\n",
- "Epoch 50/50\n",
- "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m45s\u001b[0m 923ms/step - cosine: 0.6663 - loss: 0.1214 - mae: 0.2032 - mse: 0.0949 - val_cosine: 0.8085 - val_loss: 0.0633 - val_mae: 0.1158 - val_mse: 0.0369\n"
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m16s\u001b[0m 51ms/step - cos: 0.1007 - loss: 1.3767 - mae: 0.7174 - mse: 1.0393 - snr: -4.2311 - val_cos: 0.2703 - val_loss: 1.0319 - val_mae: 0.3945 - val_mse: 0.7054 - val_snr: -0.0017\n",
+ "Epoch 2/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 18ms/step - cos: 0.2336 - loss: 0.5753 - mae: 0.3641 - mse: 0.2541 - snr: 3.7060 - val_cos: 0.2679 - val_loss: 1.0092 - val_mae: 0.3928 - val_mse: 0.7053 - val_snr: -0.0016\n",
+ "Epoch 3/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 18ms/step - cos: 0.3244 - loss: 0.4547 - mae: 0.2796 - mse: 0.1569 - snr: 6.1683 - val_cos: 0.2662 - val_loss: 0.9830 - val_mae: 0.3954 - val_mse: 0.7038 - val_snr: 0.0078\n",
+ "Epoch 4/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m2s\u001b[0m 32ms/step - cos: 0.4013 - loss: 0.3898 - mae: 0.2370 - mse: 0.1169 - snr: 7.7768 - val_cos: 0.1827 - val_loss: 0.9553 - val_mae: 0.3978 - val_mse: 0.7007 - val_snr: 0.0266\n",
+ "Epoch 5/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m2s\u001b[0m 33ms/step - cos: 0.4554 - loss: 0.3489 - mae: 0.2162 - mse: 0.1003 - snr: 8.4476 - val_cos: 0.1883 - val_loss: 0.9265 - val_mae: 0.3962 - val_mse: 0.6955 - val_snr: 0.0580\n",
+ "Epoch 6/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m2s\u001b[0m 35ms/step - cos: 0.5028 - loss: 0.3146 - mae: 0.2012 - mse: 0.0892 - snr: 8.7577 - val_cos: 0.2073 - val_loss: 0.8936 - val_mae: 0.3928 - val_mse: 0.6847 - val_snr: 0.1259\n",
+ "Epoch 7/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m2s\u001b[0m 34ms/step - cos: 0.5492 - loss: 0.2815 - mae: 0.1864 - mse: 0.0777 - snr: 9.5363 - val_cos: 0.1099 - val_loss: 0.8528 - val_mae: 0.3929 - val_mse: 0.6641 - val_snr: 0.2579\n",
+ "Epoch 8/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m2s\u001b[0m 35ms/step - cos: 0.6010 - loss: 0.2512 - mae: 0.1712 - mse: 0.0673 - snr: 10.2711 - val_cos: 0.1661 - val_loss: 0.8020 - val_mae: 0.3849 - val_mse: 0.6319 - val_snr: 0.4717\n",
+ "Epoch 9/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m2s\u001b[0m 31ms/step - cos: 0.6311 - loss: 0.2277 - mae: 0.1629 - mse: 0.0619 - snr: 10.5462 - val_cos: 0.2125 - val_loss: 0.7477 - val_mae: 0.3756 - val_mse: 0.5942 - val_snr: 0.7327\n",
+ "Epoch 10/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m2s\u001b[0m 37ms/step - cos: 0.6549 - loss: 0.2070 - mae: 0.1554 - mse: 0.0574 - snr: 10.8175 - val_cos: 0.1299 - val_loss: 0.6832 - val_mae: 0.3679 - val_mse: 0.5447 - val_snr: 1.1079\n",
+ "Epoch 11/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m2s\u001b[0m 38ms/step - cos: 0.6779 - loss: 0.1865 - mae: 0.1463 - mse: 0.0515 - snr: 11.0805 - val_cos: 0.2047 - val_loss: 0.6205 - val_mae: 0.3523 - val_mse: 0.4953 - val_snr: 1.5123\n",
+ "Epoch 12/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m2s\u001b[0m 37ms/step - cos: 0.7079 - loss: 0.1676 - mae: 0.1369 - mse: 0.0455 - snr: 11.3521 - val_cos: 0.3223 - val_loss: 0.5628 - val_mae: 0.3347 - val_mse: 0.4495 - val_snr: 1.9304\n",
+ "Epoch 13/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m2s\u001b[0m 33ms/step - cos: 0.7250 - loss: 0.1511 - mae: 0.1286 - mse: 0.0405 - snr: 12.7226 - val_cos: 0.4194 - val_loss: 0.5233 - val_mae: 0.3210 - val_mse: 0.4206 - val_snr: 2.2110\n",
+ "Epoch 14/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m2s\u001b[0m 33ms/step - cos: 0.7275 - loss: 0.1400 - mae: 0.1283 - mse: 0.0397 - snr: 12.2387 - val_cos: 0.5007 - val_loss: 0.4881 - val_mae: 0.3081 - val_mse: 0.3948 - val_snr: 2.4814\n",
+ "Epoch 15/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m2s\u001b[0m 36ms/step - cos: 0.7397 - loss: 0.1276 - mae: 0.1220 - mse: 0.0365 - snr: 12.6243 - val_cos: 0.5350 - val_loss: 0.4388 - val_mae: 0.2936 - val_mse: 0.3538 - val_snr: 2.9598\n",
+ "Epoch 16/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m2s\u001b[0m 37ms/step - cos: 0.7444 - loss: 0.1196 - mae: 0.1222 - mse: 0.0366 - snr: 13.0157 - val_cos: 0.5199 - val_loss: 0.4065 - val_mae: 0.2870 - val_mse: 0.3289 - val_snr: 3.2739\n",
+ "Epoch 17/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m2s\u001b[0m 33ms/step - cos: 0.7604 - loss: 0.1086 - mae: 0.1150 - mse: 0.0327 - snr: 13.5769 - val_cos: 0.5125 - val_loss: 0.3781 - val_mae: 0.2835 - val_mse: 0.3071 - val_snr: 3.5680\n",
+ "Epoch 18/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m2s\u001b[0m 31ms/step - cos: 0.7563 - loss: 0.1031 - mae: 0.1169 - mse: 0.0336 - snr: 12.8908 - val_cos: 0.4988 - val_loss: 0.3691 - val_mae: 0.2845 - val_mse: 0.3038 - val_snr: 3.6137\n",
+ "Epoch 19/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m2s\u001b[0m 34ms/step - cos: 0.7582 - loss: 0.0953 - mae: 0.1134 - mse: 0.0314 - snr: 13.0128 - val_cos: 0.4503 - val_loss: 0.3642 - val_mae: 0.2880 - val_mse: 0.3042 - val_snr: 3.6095\n",
+ "Epoch 20/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m2s\u001b[0m 35ms/step - cos: 0.7610 - loss: 0.0894 - mae: 0.1125 - mse: 0.0305 - snr: 13.3877 - val_cos: 0.3801 - val_loss: 0.3525 - val_mae: 0.2941 - val_mse: 0.2970 - val_snr: 3.7022\n",
+ "Epoch 21/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m2s\u001b[0m 33ms/step - cos: 0.7672 - loss: 0.0838 - mae: 0.1098 - mse: 0.0294 - snr: 13.3445 - val_cos: 0.3764 - val_loss: 0.3439 - val_mae: 0.2931 - val_mse: 0.2925 - val_snr: 3.7707\n",
+ "Epoch 22/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m2s\u001b[0m 31ms/step - cos: 0.7720 - loss: 0.0787 - mae: 0.1080 - mse: 0.0282 - snr: 13.6978 - val_cos: 0.3703 - val_loss: 0.3287 - val_mae: 0.2916 - val_mse: 0.2808 - val_snr: 3.9512\n",
+ "Epoch 23/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m2s\u001b[0m 33ms/step - cos: 0.7776 - loss: 0.0746 - mae: 0.1062 - mse: 0.0276 - snr: 13.8593 - val_cos: 0.3259 - val_loss: 0.3181 - val_mae: 0.2954 - val_mse: 0.2735 - val_snr: 4.0665\n",
+ "Epoch 24/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m2s\u001b[0m 32ms/step - cos: 0.7790 - loss: 0.0707 - mae: 0.1050 - mse: 0.0268 - snr: 13.4164 - val_cos: 0.4684 - val_loss: 0.2927 - val_mae: 0.2706 - val_mse: 0.2510 - val_snr: 4.4632\n",
+ "Epoch 25/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m2s\u001b[0m 33ms/step - cos: 0.7770 - loss: 0.0687 - mae: 0.1063 - mse: 0.0276 - snr: 13.9392 - val_cos: 0.4359 - val_loss: 0.2624 - val_mae: 0.2647 - val_mse: 0.2233 - val_snr: 4.9690\n",
+ "Epoch 26/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 27ms/step - cos: 0.7880 - loss: 0.0641 - mae: 0.1010 - mse: 0.0256 - snr: 14.5248 - val_cos: 0.3473 - val_loss: 0.2633 - val_mae: 0.2757 - val_mse: 0.2266 - val_snr: 4.9171\n",
+ "Epoch 27/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m2s\u001b[0m 34ms/step - cos: 0.7875 - loss: 0.0612 - mae: 0.1012 - mse: 0.0250 - snr: 15.0129 - val_cos: 0.5326 - val_loss: 0.2299 - val_mae: 0.2411 - val_mse: 0.1952 - val_snr: 5.5732\n",
+ "Epoch 28/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m2s\u001b[0m 36ms/step - cos: 0.7901 - loss: 0.0587 - mae: 0.1009 - mse: 0.0245 - snr: 14.6143 - val_cos: 0.6635 - val_loss: 0.2143 - val_mae: 0.2182 - val_mse: 0.1815 - val_snr: 5.9250\n",
+ "Epoch 29/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m2s\u001b[0m 31ms/step - cos: 0.7879 - loss: 0.0566 - mae: 0.1010 - mse: 0.0242 - snr: 14.2431 - val_cos: 0.6997 - val_loss: 0.1920 - val_mae: 0.2055 - val_mse: 0.1610 - val_snr: 6.4625\n",
+ "Epoch 30/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m2s\u001b[0m 31ms/step - cos: 0.7946 - loss: 0.0538 - mae: 0.0981 - mse: 0.0231 - snr: 15.0658 - val_cos: 0.6248 - val_loss: 0.1974 - val_mae: 0.2174 - val_mse: 0.1680 - val_snr: 6.2441\n",
+ "Epoch 31/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m2s\u001b[0m 35ms/step - cos: 0.7951 - loss: 0.0528 - mae: 0.0987 - mse: 0.0238 - snr: 15.2206 - val_cos: 0.6176 - val_loss: 0.2029 - val_mae: 0.2191 - val_mse: 0.1750 - val_snr: 6.0858\n",
+ "Epoch 32/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m2s\u001b[0m 35ms/step - cos: 0.7915 - loss: 0.0516 - mae: 0.0995 - mse: 0.0240 - snr: 14.4698 - val_cos: 0.6997 - val_loss: 0.1747 - val_mae: 0.1981 - val_mse: 0.1482 - val_snr: 6.8104\n",
+ "Epoch 33/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m2s\u001b[0m 31ms/step - cos: 0.7989 - loss: 0.0487 - mae: 0.0969 - mse: 0.0225 - snr: 15.0472 - val_cos: 0.6682 - val_loss: 0.1698 - val_mae: 0.1999 - val_mse: 0.1444 - val_snr: 6.9262\n",
+ "Epoch 34/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m2s\u001b[0m 37ms/step - cos: 0.7973 - loss: 0.0473 - mae: 0.0956 - mse: 0.0223 - snr: 15.3917 - val_cos: 0.6686 - val_loss: 0.1694 - val_mae: 0.1982 - val_mse: 0.1452 - val_snr: 6.9149\n",
+ "Epoch 35/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m2s\u001b[0m 37ms/step - cos: 0.7924 - loss: 0.0472 - mae: 0.0985 - mse: 0.0233 - snr: 14.3126 - val_cos: 0.7566 - val_loss: 0.1424 - val_mae: 0.1767 - val_mse: 0.1193 - val_snr: 7.7728\n",
+ "Epoch 36/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m2s\u001b[0m 31ms/step - cos: 0.8020 - loss: 0.0442 - mae: 0.0936 - mse: 0.0213 - snr: 15.2554 - val_cos: 0.7495 - val_loss: 0.1363 - val_mae: 0.1757 - val_mse: 0.1141 - val_snr: 7.9693\n",
+ "Epoch 37/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 29ms/step - cos: 0.8069 - loss: 0.0433 - mae: 0.0936 - mse: 0.0213 - snr: 15.9400 - val_cos: 0.7005 - val_loss: 0.1504 - val_mae: 0.1865 - val_mse: 0.1292 - val_snr: 7.4212\n",
+ "Epoch 38/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m2s\u001b[0m 33ms/step - cos: 0.8037 - loss: 0.0424 - mae: 0.0944 - mse: 0.0214 - snr: 15.0481 - val_cos: 0.6872 - val_loss: 0.1420 - val_mae: 0.1837 - val_mse: 0.1215 - val_snr: 7.7206\n",
+ "Epoch 39/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m2s\u001b[0m 33ms/step - cos: 0.8039 - loss: 0.0412 - mae: 0.0927 - mse: 0.0209 - snr: 15.3849 - val_cos: 0.6729 - val_loss: 0.1296 - val_mae: 0.1805 - val_mse: 0.1099 - val_snr: 8.1422\n",
+ "Epoch 40/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 30ms/step - cos: 0.8057 - loss: 0.0404 - mae: 0.0924 - mse: 0.0209 - snr: 15.1577 - val_cos: 0.6925 - val_loss: 0.1342 - val_mae: 0.1806 - val_mse: 0.1152 - val_snr: 7.9484\n",
+ "Epoch 41/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m2s\u001b[0m 35ms/step - cos: 0.8108 - loss: 0.0390 - mae: 0.0915 - mse: 0.0203 - snr: 15.0379 - val_cos: 0.6835 - val_loss: 0.1117 - val_mae: 0.1692 - val_mse: 0.0934 - val_snr: 8.8438\n",
+ "Epoch 42/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m2s\u001b[0m 33ms/step - cos: 0.8044 - loss: 0.0392 - mae: 0.0934 - mse: 0.0211 - snr: 14.9561 - val_cos: 0.6988 - val_loss: 0.1058 - val_mae: 0.1669 - val_mse: 0.0881 - val_snr: 9.0532\n",
+ "Epoch 43/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 29ms/step - cos: 0.8124 - loss: 0.0376 - mae: 0.0911 - mse: 0.0201 - snr: 15.2787 - val_cos: 0.5931 - val_loss: 0.1177 - val_mae: 0.1843 - val_mse: 0.1007 - val_snr: 8.5046\n",
+ "Epoch 44/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m2s\u001b[0m 33ms/step - cos: 0.8109 - loss: 0.0363 - mae: 0.0891 - mse: 0.0194 - snr: 15.2912 - val_cos: 0.6494 - val_loss: 0.1075 - val_mae: 0.1741 - val_mse: 0.0910 - val_snr: 8.9004\n",
+ "Epoch 45/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m2s\u001b[0m 33ms/step - cos: 0.8141 - loss: 0.0356 - mae: 0.0891 - mse: 0.0193 - snr: 15.2001 - val_cos: 0.6863 - val_loss: 0.1130 - val_mae: 0.1728 - val_mse: 0.0970 - val_snr: 8.6605\n",
+ "Epoch 46/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m2s\u001b[0m 35ms/step - cos: 0.8078 - loss: 0.0369 - mae: 0.0930 - mse: 0.0210 - snr: 14.5843 - val_cos: 0.6935 - val_loss: 0.0959 - val_mae: 0.1626 - val_mse: 0.0804 - val_snr: 9.4516\n",
+ "Epoch 47/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m2s\u001b[0m 35ms/step - cos: 0.8103 - loss: 0.0353 - mae: 0.0904 - mse: 0.0199 - snr: 15.7789 - val_cos: 0.7251 - val_loss: 0.0915 - val_mae: 0.1560 - val_mse: 0.0765 - val_snr: 9.6838\n",
+ "Epoch 48/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 27ms/step - cos: 0.8127 - loss: 0.0341 - mae: 0.0891 - mse: 0.0192 - snr: 16.5469 - val_cos: 0.7214 - val_loss: 0.0926 - val_mae: 0.1577 - val_mse: 0.0780 - val_snr: 9.6104\n",
+ "Epoch 49/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m2s\u001b[0m 35ms/step - cos: 0.8171 - loss: 0.0327 - mae: 0.0871 - mse: 0.0182 - snr: 16.5750 - val_cos: 0.7358 - val_loss: 0.0838 - val_mae: 0.1494 - val_mse: 0.0696 - val_snr: 10.1036\n",
+ "Epoch 50/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m2s\u001b[0m 34ms/step - cos: 0.8028 - loss: 0.0353 - mae: 0.0947 - mse: 0.0212 - snr: 14.5850 - val_cos: 0.7540 - val_loss: 0.0753 - val_mae: 0.1480 - val_mse: 0.0615 - val_snr: 10.5979\n",
+ "Epoch 51/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m2s\u001b[0m 31ms/step - cos: 0.8126 - loss: 0.0332 - mae: 0.0895 - mse: 0.0195 - snr: 16.2668 - val_cos: 0.7027 - val_loss: 0.0914 - val_mae: 0.1584 - val_mse: 0.0779 - val_snr: 9.6309\n",
+ "Epoch 52/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m2s\u001b[0m 31ms/step - cos: 0.8142 - loss: 0.0322 - mae: 0.0886 - mse: 0.0188 - snr: 15.1897 - val_cos: 0.7682 - val_loss: 0.0797 - val_mae: 0.1425 - val_mse: 0.0665 - val_snr: 10.2973\n",
+ "Epoch 53/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m2s\u001b[0m 33ms/step - cos: 0.8129 - loss: 0.0326 - mae: 0.0900 - mse: 0.0195 - snr: 15.5567 - val_cos: 0.7554 - val_loss: 0.0746 - val_mae: 0.1433 - val_mse: 0.0618 - val_snr: 10.5957\n",
+ "Epoch 54/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 24ms/step - cos: 0.8123 - loss: 0.0322 - mae: 0.0896 - mse: 0.0195 - snr: 15.3820 - val_cos: 0.6952 - val_loss: 0.0748 - val_mae: 0.1518 - val_mse: 0.0622 - val_snr: 10.5582\n",
+ "Epoch 55/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 27ms/step - cos: 0.8218 - loss: 0.0304 - mae: 0.0853 - mse: 0.0179 - snr: 16.4468 - val_cos: 0.7383 - val_loss: 0.0686 - val_mae: 0.1399 - val_mse: 0.0563 - val_snr: 11.0081\n",
+ "Epoch 56/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 22ms/step - cos: 0.8137 - loss: 0.0305 - mae: 0.0877 - mse: 0.0183 - snr: 15.4239 - val_cos: 0.7563 - val_loss: 0.0651 - val_mae: 0.1356 - val_mse: 0.0531 - val_snr: 11.2572\n",
+ "Epoch 57/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 27ms/step - cos: 0.8176 - loss: 0.0309 - mae: 0.0883 - mse: 0.0190 - snr: 15.8349 - val_cos: 0.7482 - val_loss: 0.0563 - val_mae: 0.1298 - val_mse: 0.0445 - val_snr: 12.0380\n",
+ "Epoch 58/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 22ms/step - cos: 0.8229 - loss: 0.0298 - mae: 0.0865 - mse: 0.0181 - snr: 15.4568 - val_cos: 0.7121 - val_loss: 0.0618 - val_mae: 0.1381 - val_mse: 0.0503 - val_snr: 11.4883\n",
+ "Epoch 59/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 29ms/step - cos: 0.8189 - loss: 0.0297 - mae: 0.0867 - mse: 0.0182 - snr: 15.8944 - val_cos: 0.7772 - val_loss: 0.0558 - val_mae: 0.1253 - val_mse: 0.0445 - val_snr: 12.0029\n",
+ "Epoch 60/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 21ms/step - cos: 0.8167 - loss: 0.0292 - mae: 0.0865 - mse: 0.0179 - snr: 16.4350 - val_cos: 0.7391 - val_loss: 0.0634 - val_mae: 0.1358 - val_mse: 0.0524 - val_snr: 11.3357\n",
+ "Epoch 61/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 27ms/step - cos: 0.8167 - loss: 0.0291 - mae: 0.0872 - mse: 0.0181 - snr: 15.7276 - val_cos: 0.7804 - val_loss: 0.0539 - val_mae: 0.1253 - val_mse: 0.0430 - val_snr: 12.1341\n",
+ "Epoch 62/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 28ms/step - cos: 0.8181 - loss: 0.0294 - mae: 0.0867 - mse: 0.0186 - snr: 16.1558 - val_cos: 0.7621 - val_loss: 0.0524 - val_mae: 0.1272 - val_mse: 0.0417 - val_snr: 12.2439\n",
+ "Epoch 63/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 22ms/step - cos: 0.8079 - loss: 0.0308 - mae: 0.0911 - mse: 0.0202 - snr: 15.5905 - val_cos: 0.7695 - val_loss: 0.0555 - val_mae: 0.1271 - val_mse: 0.0449 - val_snr: 11.9835\n",
+ "Epoch 64/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 29ms/step - cos: 0.8296 - loss: 0.0269 - mae: 0.0814 - mse: 0.0165 - snr: 16.4142 - val_cos: 0.7906 - val_loss: 0.0421 - val_mae: 0.1112 - val_mse: 0.0317 - val_snr: 13.4501\n",
+ "Epoch 65/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 21ms/step - cos: 0.8187 - loss: 0.0277 - mae: 0.0854 - mse: 0.0174 - snr: 16.0411 - val_cos: 0.7838 - val_loss: 0.0482 - val_mae: 0.1190 - val_mse: 0.0380 - val_snr: 12.6759\n",
+ "Epoch 66/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 27ms/step - cos: 0.8100 - loss: 0.0295 - mae: 0.0894 - mse: 0.0193 - snr: 15.3396 - val_cos: 0.8025 - val_loss: 0.0454 - val_mae: 0.1133 - val_mse: 0.0353 - val_snr: 12.9731\n",
+ "Epoch 67/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 28ms/step - cos: 0.8188 - loss: 0.0283 - mae: 0.0864 - mse: 0.0182 - snr: 16.0004 - val_cos: 0.7764 - val_loss: 0.0436 - val_mae: 0.1164 - val_mse: 0.0337 - val_snr: 13.1719\n",
+ "Epoch 68/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 26ms/step - cos: 0.8202 - loss: 0.0280 - mae: 0.0865 - mse: 0.0181 - snr: 16.2730 - val_cos: 0.7787 - val_loss: 0.0477 - val_mae: 0.1198 - val_mse: 0.0379 - val_snr: 12.6531\n",
+ "Epoch 69/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 25ms/step - cos: 0.8280 - loss: 0.0271 - mae: 0.0832 - mse: 0.0173 - snr: 16.3682 - val_cos: 0.7794 - val_loss: 0.0456 - val_mae: 0.1169 - val_mse: 0.0359 - val_snr: 12.9183\n",
+ "Epoch 70/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 26ms/step - cos: 0.8217 - loss: 0.0271 - mae: 0.0847 - mse: 0.0174 - snr: 16.6814 - val_cos: 0.7840 - val_loss: 0.0449 - val_mae: 0.1155 - val_mse: 0.0354 - val_snr: 12.9648\n",
+ "Epoch 71/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m2s\u001b[0m 30ms/step - cos: 0.8285 - loss: 0.0267 - mae: 0.0829 - mse: 0.0171 - snr: 16.6853 - val_cos: 0.8066 - val_loss: 0.0376 - val_mae: 0.1039 - val_mse: 0.0281 - val_snr: 13.9514\n",
+ "Epoch 72/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 24ms/step - cos: 0.8234 - loss: 0.0267 - mae: 0.0847 - mse: 0.0173 - snr: 16.2299 - val_cos: 0.8104 - val_loss: 0.0408 - val_mae: 0.1069 - val_mse: 0.0315 - val_snr: 13.4943\n",
+ "Epoch 73/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 28ms/step - cos: 0.8229 - loss: 0.0265 - mae: 0.0836 - mse: 0.0172 - snr: 16.1746 - val_cos: 0.8173 - val_loss: 0.0349 - val_mae: 0.0986 - val_mse: 0.0256 - val_snr: 14.3747\n",
+ "Epoch 74/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 22ms/step - cos: 0.8256 - loss: 0.0265 - mae: 0.0830 - mse: 0.0172 - snr: 15.9057 - val_cos: 0.8169 - val_loss: 0.0379 - val_mae: 0.1032 - val_mse: 0.0287 - val_snr: 13.8832\n",
+ "Epoch 75/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 22ms/step - cos: 0.8245 - loss: 0.0267 - mae: 0.0837 - mse: 0.0175 - snr: 16.2338 - val_cos: 0.8137 - val_loss: 0.0362 - val_mae: 0.1011 - val_mse: 0.0271 - val_snr: 14.1791\n",
+ "Epoch 76/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 24ms/step - cos: 0.8236 - loss: 0.0265 - mae: 0.0830 - mse: 0.0174 - snr: 16.2230 - val_cos: 0.8141 - val_loss: 0.0360 - val_mae: 0.1005 - val_mse: 0.0270 - val_snr: 14.1546\n",
+ "Epoch 77/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m2s\u001b[0m 30ms/step - cos: 0.8213 - loss: 0.0266 - mae: 0.0854 - mse: 0.0176 - snr: 15.7473 - val_cos: 0.8149 - val_loss: 0.0325 - val_mae: 0.0960 - val_mse: 0.0235 - val_snr: 14.7467\n",
+ "Epoch 78/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 26ms/step - cos: 0.8232 - loss: 0.0258 - mae: 0.0834 - mse: 0.0168 - snr: 15.6876 - val_cos: 0.8110 - val_loss: 0.0324 - val_mae: 0.0961 - val_mse: 0.0235 - val_snr: 14.8023\n",
+ "Epoch 79/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 23ms/step - cos: 0.8152 - loss: 0.0266 - mae: 0.0870 - mse: 0.0177 - snr: 15.8633 - val_cos: 0.8188 - val_loss: 0.0328 - val_mae: 0.0957 - val_mse: 0.0239 - val_snr: 14.7028\n",
+ "Epoch 80/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 23ms/step - cos: 0.8348 - loss: 0.0245 - mae: 0.0791 - mse: 0.0157 - snr: 17.1155 - val_cos: 0.8257 - val_loss: 0.0328 - val_mae: 0.0947 - val_mse: 0.0240 - val_snr: 14.6474\n",
+ "Epoch 81/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 26ms/step - cos: 0.8125 - loss: 0.0266 - mae: 0.0858 - mse: 0.0178 - snr: 16.5905 - val_cos: 0.8231 - val_loss: 0.0293 - val_mae: 0.0891 - val_mse: 0.0206 - val_snr: 15.3167\n",
+ "Epoch 82/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 20ms/step - cos: 0.8152 - loss: 0.0277 - mae: 0.0872 - mse: 0.0189 - snr: 15.6371 - val_cos: 0.8232 - val_loss: 0.0296 - val_mae: 0.0905 - val_mse: 0.0209 - val_snr: 15.2771\n",
+ "Epoch 83/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 23ms/step - cos: 0.8242 - loss: 0.0263 - mae: 0.0836 - mse: 0.0176 - snr: 15.1988 - val_cos: 0.8232 - val_loss: 0.0298 - val_mae: 0.0904 - val_mse: 0.0211 - val_snr: 15.2139\n",
+ "Epoch 84/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 26ms/step - cos: 0.8190 - loss: 0.0272 - mae: 0.0864 - mse: 0.0185 - snr: 15.4599 - val_cos: 0.8166 - val_loss: 0.0289 - val_mae: 0.0902 - val_mse: 0.0202 - val_snr: 15.4350\n",
+ "Epoch 85/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 21ms/step - cos: 0.8156 - loss: 0.0268 - mae: 0.0864 - mse: 0.0182 - snr: 15.9789 - val_cos: 0.8194 - val_loss: 0.0291 - val_mae: 0.0904 - val_mse: 0.0205 - val_snr: 15.3796\n",
+ "Epoch 86/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 27ms/step - cos: 0.8163 - loss: 0.0268 - mae: 0.0866 - mse: 0.0182 - snr: 15.2712 - val_cos: 0.8258 - val_loss: 0.0280 - val_mae: 0.0871 - val_mse: 0.0194 - val_snr: 15.6015\n",
+ "Epoch 87/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 22ms/step - cos: 0.8230 - loss: 0.0263 - mae: 0.0842 - mse: 0.0177 - snr: 15.3978 - val_cos: 0.8237 - val_loss: 0.0281 - val_mae: 0.0881 - val_mse: 0.0196 - val_snr: 15.5894\n",
+ "Epoch 88/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 23ms/step - cos: 0.8182 - loss: 0.0265 - mae: 0.0864 - mse: 0.0180 - snr: 15.0980 - val_cos: 0.8272 - val_loss: 0.0284 - val_mae: 0.0878 - val_mse: 0.0198 - val_snr: 15.5125\n",
+ "Epoch 89/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 28ms/step - cos: 0.8169 - loss: 0.0262 - mae: 0.0860 - mse: 0.0177 - snr: 15.9089 - val_cos: 0.8285 - val_loss: 0.0273 - val_mae: 0.0857 - val_mse: 0.0187 - val_snr: 15.7466\n",
+ "Epoch 90/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 29ms/step - cos: 0.8182 - loss: 0.0260 - mae: 0.0842 - mse: 0.0174 - snr: 15.6634 - val_cos: 0.8285 - val_loss: 0.0270 - val_mae: 0.0853 - val_mse: 0.0185 - val_snr: 15.8044\n",
+ "Epoch 91/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 21ms/step - cos: 0.8343 - loss: 0.0240 - mae: 0.0792 - mse: 0.0154 - snr: 17.3884 - val_cos: 0.8296 - val_loss: 0.0270 - val_mae: 0.0849 - val_mse: 0.0185 - val_snr: 15.7994\n",
+ "Epoch 92/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 26ms/step - cos: 0.8238 - loss: 0.0255 - mae: 0.0826 - mse: 0.0170 - snr: 16.4601 - val_cos: 0.8312 - val_loss: 0.0267 - val_mae: 0.0840 - val_mse: 0.0182 - val_snr: 15.8516\n",
+ "Epoch 93/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 28ms/step - cos: 0.8242 - loss: 0.0261 - mae: 0.0842 - mse: 0.0176 - snr: 15.4825 - val_cos: 0.8315 - val_loss: 0.0263 - val_mae: 0.0832 - val_mse: 0.0178 - val_snr: 15.9499\n",
+ "Epoch 94/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 25ms/step - cos: 0.8195 - loss: 0.0261 - mae: 0.0850 - mse: 0.0176 - snr: 15.9241 - val_cos: 0.8300 - val_loss: 0.0260 - val_mae: 0.0829 - val_mse: 0.0175 - val_snr: 16.0251\n",
+ "Epoch 95/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 26ms/step - cos: 0.8259 - loss: 0.0254 - mae: 0.0829 - mse: 0.0169 - snr: 16.1511 - val_cos: 0.8305 - val_loss: 0.0257 - val_mae: 0.0822 - val_mse: 0.0172 - val_snr: 16.1082\n",
+ "Epoch 96/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 24ms/step - cos: 0.8178 - loss: 0.0254 - mae: 0.0843 - mse: 0.0170 - snr: 15.9857 - val_cos: 0.8314 - val_loss: 0.0261 - val_mae: 0.0829 - val_mse: 0.0176 - val_snr: 16.0047\n",
+ "Epoch 97/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 27ms/step - cos: 0.8338 - loss: 0.0249 - mae: 0.0802 - mse: 0.0165 - snr: 16.4559 - val_cos: 0.8313 - val_loss: 0.0256 - val_mae: 0.0819 - val_mse: 0.0171 - val_snr: 16.1225\n",
+ "Epoch 98/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 23ms/step - cos: 0.8218 - loss: 0.0257 - mae: 0.0837 - mse: 0.0172 - snr: 16.6451 - val_cos: 0.8313 - val_loss: 0.0254 - val_mae: 0.0814 - val_mse: 0.0169 - val_snr: 16.1837\n",
+ "Epoch 99/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 19ms/step - cos: 0.8187 - loss: 0.0254 - mae: 0.0843 - mse: 0.0169 - snr: 15.8321 - val_cos: 0.8316 - val_loss: 0.0254 - val_mae: 0.0815 - val_mse: 0.0169 - val_snr: 16.1731\n",
+ "Epoch 100/100\n",
+ "\u001b[1m50/50\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 26ms/step - cos: 0.8245 - loss: 0.0244 - mae: 0.0812 - mse: 0.0159 - snr: 17.1558 - val_cos: 0.8316 - val_loss: 0.0255 - val_mae: 0.0817 - val_mse: 0.0170 - val_snr: 16.1516\n"
]
}
],
"source": [
- "task.train(train_params)"
+ "history = model.fit(\n",
+ " train_ds,\n",
+ " steps_per_epoch=steps_per_epoch,\n",
+ " verbose=verbose,\n",
+ " epochs=epochs,\n",
+ " validation_data=val_ds,\n",
+ " callbacks=model_callbacks,\n",
+ ")"
]
},
{
@@ -798,12 +1114,12 @@
},
{
"cell_type": "code",
- "execution_count": 13,
+ "execution_count": 17,
"metadata": {},
"outputs": [
{
"data": {
- "image/png": "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",
+ "image/png": "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",
"text/plain": [
""
]
@@ -813,15 +1129,16 @@
}
],
"source": [
- "history = pd.read_csv(job_dir / \"history.csv\")\n",
- "fig, ax = plt.subplots(2, 1, figsize=(9, 5))\n",
- "history[[\"loss\", \"val_loss\"]].plot(ax=ax[0], color=[primary_color, secondary_color])\n",
- "history[[\"cosine\", \"val_cosine\"]].plot(ax=ax[1], color=[primary_color, secondary_color])\n",
- "# set x-axis label\n",
- "ax[1].set_xlabel(\"Epoch\")\n",
- "ax[0].set_ylabel(\"Loss\")\n",
- "ax[1].set_ylabel(\"Cosine Similarity\")\n",
- "plt.show()"
+ "fig, _ = nse.plotting.plot_history_metrics(\n",
+ " history.history,\n",
+ " metrics=[\"loss\", \"cos\"],\n",
+ " title=\"Training History\",\n",
+ " colors=[plot_theme.primary_color, plot_theme.secondary_color],\n",
+ " stack=True,\n",
+ " figsize=(9, 5),\n",
+ ")\n",
+ "fig.tight_layout()\n",
+ "fig.show()"
]
},
{
@@ -830,82 +1147,29 @@
"source": [
"## Model evaluation\n",
"\n",
- "Now that we have trained the model, we will evaluate the model on the test dataset. Similar to training, we will provide a high-level configuration to the task process."
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 14,
- "metadata": {},
- "outputs": [],
- "source": [
- "test_params = hk.HKTestParams(\n",
- " job_dir=job_dir, # Directory to store all output artifacts\n",
- " datasets=datasets, # Datasets to test on\n",
- " sampling_rate=sampling_rate, # Target sampling rate\n",
- " frame_size=frame_size, # Target frame size\n",
- " test_samples_per_patient=samples_per_patient, # Samples per test patient\n",
- " test_size=test_size, # Number of samples to test\n",
- " test_file=val_file, # Validation file (cached)\n",
- " preprocesses=preprocesses, # Preprocessing pipeline\n",
- " model_file=model_file, # Model file to load\n",
- " verbose=verbose # Verbosity level\n",
- ")"
+ "Now that we have trained the model, we will evaluate the model on the test dataset. The model's built-in `evaluate` method will be used to calculate the loss and metrics on the dataset."
]
},
{
"cell_type": "code",
- "execution_count": 15,
+ "execution_count": 18,
"metadata": {},
"outputs": [
- {
- "data": {
- "text/html": [
- " \n"
- ],
- "text/plain": []
- },
- "metadata": {},
- "output_type": "display_data"
- },
- {
- "name": "stderr",
- "output_type": "stream",
- "text": [
- "WARNING: All log messages before absl::InitializeLog() is called are written to STDERR\n",
- "I0000 00:00:1721319410.488057 950921 service.cc:146] XLA service 0x79fec0014730 initialized for platform CUDA (this does not guarantee that XLA will be used). Devices:\n",
- "I0000 00:00:1721319410.488079 950921 service.cc:154] StreamExecutor device (0): NVIDIA GeForce RTX 4090, Compute Capability 8.9\n"
- ]
- },
{
"name": "stdout",
"output_type": "stream",
"text": [
- "\u001b[1m114/157\u001b[0m \u001b[32m━━━━━━━━━━━━━━\u001b[0m\u001b[37m━━━━━━\u001b[0m \u001b[1m0s\u001b[0m 445us/step "
- ]
- },
- {
- "name": "stderr",
- "output_type": "stream",
- "text": [
- "I0000 00:00:1721319411.201827 950921 device_compiler.h:188] Compiled cluster using XLA! This line is logged at most once for the lifetime of the process.\n"
- ]
- },
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "\u001b[1m157/157\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 4ms/step\n"
+ "\u001b[1m39/39\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m0s\u001b[0m 1ms/step - cos: 0.8275 - loss: 0.0258 - mae: 0.0831 - mse: 0.0173 - snr: 16.1862\n"
]
},
{
"data": {
"text/html": [
- "[07/18/24 16:16:52] INFO [ TEST SET] MAE =11.40 %, MSE =3.60 %, COSSIM =98.11 % evaluate.py : 70 \n",
+ "INFO [ VAL SET] COS =0.8313 , LOSS =0.0254 , MAE =0.0814 , MSE =0.0169 , SNR =16.1837 935393270.py : 2 \n",
" \n"
],
"text/plain": [
- "\u001b[2;36m[07/18/24 16:16:52]\u001b[0m\u001b[2;36m \u001b[0m\u001b[34mINFO \u001b[0m \u001b[1m[\u001b[0mTEST SET\u001b[1m]\u001b[0m \u001b[33mMAE\u001b[0m=\u001b[1;36m11\u001b[0m\u001b[1;36m.40\u001b[0m%, \u001b[33mMSE\u001b[0m=\u001b[1;36m3\u001b[0m\u001b[1;36m.60\u001b[0m%, \u001b[33mCOSSIM\u001b[0m=\u001b[1;36m98\u001b[0m\u001b[1;36m.11\u001b[0m% \u001b]8;id=882450;file:///workspaces/heartkit/heartkit/tasks/denoise/evaluate.py\u001b\\\u001b[2mevaluate.py\u001b[0m\u001b]8;;\u001b\\\u001b[2m:\u001b[0m\u001b]8;id=36148;file:///workspaces/heartkit/heartkit/tasks/denoise/evaluate.py#70\u001b\\\u001b[2m70\u001b[0m\u001b]8;;\u001b\\\n"
+ "\u001b[34mINFO \u001b[0m \u001b[1m[\u001b[0mVAL SET\u001b[1m]\u001b[0m \u001b[33mCOS\u001b[0m=\u001b[1;36m0\u001b[0m\u001b[1;36m.8313\u001b[0m, \u001b[33mLOSS\u001b[0m=\u001b[1;36m0\u001b[0m\u001b[1;36m.0254\u001b[0m, \u001b[33mMAE\u001b[0m=\u001b[1;36m0\u001b[0m\u001b[1;36m.0814\u001b[0m, \u001b[33mMSE\u001b[0m=\u001b[1;36m0\u001b[0m\u001b[1;36m.0169\u001b[0m, \u001b[33mSNR\u001b[0m=\u001b[1;36m16\u001b[0m\u001b[1;36m.1837\u001b[0m \u001b]8;id=332795;file:///tmp/ipykernel_1619872/935393270.py\u001b\\\u001b[2m935393270.py\u001b[0m\u001b]8;;\u001b\\\u001b[2m:\u001b[0m\u001b]8;id=588225;file:///tmp/ipykernel_1619872/935393270.py#2\u001b\\\u001b[2m2\u001b[0m\u001b]8;;\u001b\\\n"
]
},
"metadata": {},
@@ -913,7 +1177,8 @@
}
],
"source": [
- "task.evaluate(test_params)"
+ "rst = model.evaluate(val_ds, return_dict=True)\n",
+ "logger.info(\"[VAL SET] \" + \", \".join([f\"{k.upper()}={v:.4f}\" for k, v in rst.items()]))"
]
},
{
@@ -935,129 +1200,123 @@
"metadata": {},
"outputs": [],
"source": [
- "quantization = hk.QuantizationParams(\n",
- " enabled=True,\n",
- " format=\"FP32\",\n",
- " io_type=\"float32\",\n",
- " conversion=\"CONCRETE\",\n",
- ")"
+ "# Convert validation dataset to numpy arrays\n",
+ "test_x = np.concatenate([x for x, _ in val_ds.as_numpy_iterator()])\n",
+ "test_y = np.concatenate([y for _, y in val_ds.as_numpy_iterator()])\n"
]
},
{
"cell_type": "code",
"execution_count": 20,
"metadata": {},
+ "outputs": [
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "W0000 00:00:1723573422.366791 1619872 tf_tfl_flatbuffer_helpers.cc:392] Ignored output_format.\n",
+ "W0000 00:00:1723573422.366803 1619872 tf_tfl_flatbuffer_helpers.cc:395] Ignored drop_control_dependency.\n"
+ ]
+ }
+ ],
+ "source": [
+ "converter = nse.converters.tflite.TfLiteKerasConverter(model=model)\n",
+ "\n",
+ "# Redirect stdout and stderr to devnull since TFLite converter is very verbose\n",
+ "with open(os.devnull, 'w') as devnull:\n",
+ " with contextlib.redirect_stdout(devnull), contextlib.redirect_stderr(devnull):\n",
+ " tflite_content = converter.convert(\n",
+ " test_x=test_x,\n",
+ " quantization=\"FP32\",\n",
+ " io_type=\"float32\",\n",
+ " mode=\"KERAS\",\n",
+ " strict=False,\n",
+ " verbose=verbose\n",
+ " )"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "### Save TFLite model as both a file and C header"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 21,
+ "metadata": {},
"outputs": [],
"source": [
- "export_params = hk.HKExportParams(\n",
- " job_dir=job_dir, # Directory to store all output artifacts\n",
- " datasets=datasets, # Datasets to export on\n",
- " sampling_rate=sampling_rate, # Target sampling rate\n",
- " frame_size=frame_size, # Target frame size\n",
- " # Test params\n",
- " test_samples_per_patient=samples_per_patient, # Samples per test patient\n",
- " test_size=test_size, # Number of samples to test\n",
- " test_file=val_file, # Validation file (cached)\n",
- " preprocesses=preprocesses, # Preprocessing pipeline\n",
- " model_file=model_file, # Model file to load\n",
- " val_acc_threshold=0.9, # Validation accuracy threshold\n",
- " quantization=quantization, # Quantization parameters\n",
- " tflm_var_name=\"rhythm\", # TFLite model variable name\n",
- " tflm_file=job_dir / \"rhythm_flatbuffer.h\", # TFLite model file\n",
- " verbose=verbose # Verbosity level\n",
+ "converter.export(\n",
+ " tflite_path=job_dir / \"model.tflite\"\n",
+ ")\n",
+ "\n",
+ "converter.export_header(\n",
+ " header_path=job_dir / \"model.h\",\n",
+ " name=\"model\",\n",
")\n"
]
},
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "### Evaluate TFLite model against TensorFlow model\n",
+ "\n",
+ "We will instantiate a tflite interpreter and evaluate the model on the test dataset. This will help us ensure that the model has been exported correctly and is ready for deployment."
+ ]
+ },
{
"cell_type": "code",
- "execution_count": 21,
+ "execution_count": 22,
"metadata": {},
"outputs": [
- {
- "data": {
- "text/html": [
- " \n"
- ],
- "text/plain": []
- },
- "metadata": {},
- "output_type": "display_data"
- },
- {
- "data": {
- "text/html": [
- "[07/18/24 16:17:17] WARNING WARNING:absl:Please consider providing the trackable_obj argument in the lite.py : 2166 \n",
- " from_concrete_functions. Providing without the trackable_obj argument is \n",
- " deprecated and it will use the deprecated conversion path. \n",
- " \n"
- ],
- "text/plain": [
- "\u001b[2;36m[07/18/24 16:17:17]\u001b[0m\u001b[2;36m \u001b[0m\u001b[31mWARNING \u001b[0m WARNING:absl:Please consider providing the trackable_obj argument in the \u001b]8;id=561352;file:///workspaces/heartkit/.venv/lib/python3.12/site-packages/tensorflow/lite/python/lite.py\u001b\\\u001b[2mlite.py\u001b[0m\u001b]8;;\u001b\\\u001b[2m:\u001b[0m\u001b]8;id=369565;file:///workspaces/heartkit/.venv/lib/python3.12/site-packages/tensorflow/lite/python/lite.py#2166\u001b\\\u001b[2m2166\u001b[0m\u001b]8;;\u001b\\\n",
- "\u001b[2;36m \u001b[0m from_concrete_functions. Providing without the trackable_obj argument is \u001b[2m \u001b[0m\n",
- "\u001b[2;36m \u001b[0m deprecated and it will use the deprecated conversion path. \u001b[2m \u001b[0m\n"
- ]
- },
- "metadata": {},
- "output_type": "display_data"
- },
- {
- "data": {
- "text/html": [
- " INFO INFO:absl:Using new converter: If you encounter a problem please file a lite.py : 1459 \n",
- " bug. You can opt-out by setting experimental_new_converter =False \n",
- " \n"
- ],
- "text/plain": [
- "\u001b[2;36m \u001b[0m\u001b[2;36m \u001b[0m\u001b[34mINFO \u001b[0m INFO:absl:Using new converter: If you encounter a problem please file a \u001b]8;id=853103;file:///workspaces/heartkit/.venv/lib/python3.12/site-packages/tensorflow/lite/python/lite.py\u001b\\\u001b[2mlite.py\u001b[0m\u001b]8;;\u001b\\\u001b[2m:\u001b[0m\u001b]8;id=282503;file:///workspaces/heartkit/.venv/lib/python3.12/site-packages/tensorflow/lite/python/lite.py#1459\u001b\\\u001b[2m1459\u001b[0m\u001b]8;;\u001b\\\n",
- "\u001b[2;36m \u001b[0m bug. You can opt-out by setting \u001b[33mexperimental_new_converter\u001b[0m=\u001b[3;91mFalse\u001b[0m \u001b[2m \u001b[0m\n"
- ]
- },
- "metadata": {},
- "output_type": "display_data"
- },
- {
- "data": {
- "text/html": [
- "[07/18/24 16:17:17] INFO Validating model results export.py : 86 \n",
- " \n"
- ],
- "text/plain": [
- "\u001b[2;36m[07/18/24 16:17:17]\u001b[0m\u001b[2;36m \u001b[0m\u001b[34mINFO \u001b[0m Validating model results \u001b]8;id=592597;file:///workspaces/heartkit/heartkit/tasks/denoise/export.py\u001b\\\u001b[2mexport.py\u001b[0m\u001b]8;;\u001b\\\u001b[2m:\u001b[0m\u001b]8;id=498654;file:///workspaces/heartkit/heartkit/tasks/denoise/export.py#86\u001b\\\u001b[2m86\u001b[0m\u001b]8;;\u001b\\\n"
- ]
- },
- "metadata": {},
- "output_type": "display_data"
- },
{
"name": "stderr",
"output_type": "stream",
"text": [
- "I0000 00:00:1721319437.392916 909880 devices.cc:67] Number of eligible GPUs (core count >= 8, compute capability >= 0.0): 1\n",
- "W0000 00:00:1721319437.474874 909880 tf_tfl_flatbuffer_helpers.cc:392] Ignored output_format.\n",
- "W0000 00:00:1721319437.474884 909880 tf_tfl_flatbuffer_helpers.cc:395] Ignored drop_control_dependency.\n"
+ "INFO: Created TensorFlow Lite XNNPACK delegate for CPU.\n"
]
- },
+ }
+ ],
+ "source": [
+ "tflite = nse.interpreters.tflite.TfLiteKerasInterpreter(tflite_content)\n",
+ "tflite.compile()"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 23,
+ "metadata": {},
+ "outputs": [
{
- "data": {
- "text/html": [
- "[07/18/24 16:17:18] INFO [ TF SET] MAE =11.36 %, RMSE =18.92 % export.py : 93 \n",
- " \n"
- ],
- "text/plain": [
- "\u001b[2;36m[07/18/24 16:17:18]\u001b[0m\u001b[2;36m \u001b[0m\u001b[34mINFO \u001b[0m \u001b[1m[\u001b[0mTF SET\u001b[1m]\u001b[0m \u001b[33mMAE\u001b[0m=\u001b[1;36m11\u001b[0m\u001b[1;36m.36\u001b[0m%, \u001b[33mRMSE\u001b[0m=\u001b[1;36m18\u001b[0m\u001b[1;36m.92\u001b[0m% \u001b]8;id=176553;file:///workspaces/heartkit/heartkit/tasks/denoise/export.py\u001b\\\u001b[2mexport.py\u001b[0m\u001b]8;;\u001b\\\u001b[2m:\u001b[0m\u001b]8;id=724123;file:///workspaces/heartkit/heartkit/tasks/denoise/export.py#93\u001b\\\u001b[2m93\u001b[0m\u001b]8;;\u001b\\\n"
- ]
- },
- "metadata": {},
- "output_type": "display_data"
- },
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "\u001b[1m312/312\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 1ms/step\n"
+ ]
+ }
+ ],
+ "source": [
+ "y_true = test_y\n",
+ "y_pred_tf = model.predict(test_x)\n",
+ "y_pred_tfl = tflite.predict(x=test_x)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 24,
+ "metadata": {},
+ "outputs": [
{
"data": {
"text/html": [
- " INFO [ TFL SET] MAE =11.36 %, RMSE =18.92 % export.py : 97 \n",
+ "INFO [ TF METRICS] MAE =0.0814 MSE =0.0169 COS =0.8313 SNR =16.2114 776805021.py : 3 \n",
" \n"
],
"text/plain": [
- "\u001b[2;36m \u001b[0m\u001b[2;36m \u001b[0m\u001b[34mINFO \u001b[0m \u001b[1m[\u001b[0mTFL SET\u001b[1m]\u001b[0m \u001b[33mMAE\u001b[0m=\u001b[1;36m11\u001b[0m\u001b[1;36m.36\u001b[0m%, \u001b[33mRMSE\u001b[0m=\u001b[1;36m18\u001b[0m\u001b[1;36m.92\u001b[0m% \u001b]8;id=616120;file:///workspaces/heartkit/heartkit/tasks/denoise/export.py\u001b\\\u001b[2mexport.py\u001b[0m\u001b]8;;\u001b\\\u001b[2m:\u001b[0m\u001b]8;id=443937;file:///workspaces/heartkit/heartkit/tasks/denoise/export.py#97\u001b\\\u001b[2m97\u001b[0m\u001b]8;;\u001b\\\n"
+ "\u001b[34mINFO \u001b[0m \u001b[1m[\u001b[0mTF METRICS\u001b[1m]\u001b[0m \u001b[33mMAE\u001b[0m=\u001b[1;36m0\u001b[0m\u001b[1;36m.0814\u001b[0m \u001b[33mMSE\u001b[0m=\u001b[1;36m0\u001b[0m\u001b[1;36m.0169\u001b[0m \u001b[33mCOS\u001b[0m=\u001b[1;36m0\u001b[0m\u001b[1;36m.8313\u001b[0m \u001b[33mSNR\u001b[0m=\u001b[1;36m16\u001b[0m\u001b[1;36m.2114\u001b[0m \u001b]8;id=370040;file:///tmp/ipykernel_1619872/776805021.py\u001b\\\u001b[2m776805021.py\u001b[0m\u001b]8;;\u001b\\\u001b[2m:\u001b[0m\u001b]8;id=166028;file:///tmp/ipykernel_1619872/776805021.py#3\u001b\\\u001b[2m3\u001b[0m\u001b]8;;\u001b\\\n"
]
},
"metadata": {},
@@ -1066,11 +1325,11 @@
{
"data": {
"text/html": [
- " INFO Validation passed ( 0.00 %) export.py : 104 \n",
+ "INFO [ TFL METRICS] MAE =0.0814 MSE =0.0169 COS =0.8313 SNR =16.2097 776805021.py : 4 \n",
" \n"
],
"text/plain": [
- "\u001b[2;36m \u001b[0m\u001b[2;36m \u001b[0m\u001b[34mINFO \u001b[0m Validation passed \u001b[1m(\u001b[0m\u001b[1;36m0.00\u001b[0m%\u001b[1m)\u001b[0m \u001b]8;id=737251;file:///workspaces/heartkit/heartkit/tasks/denoise/export.py\u001b\\\u001b[2mexport.py\u001b[0m\u001b]8;;\u001b\\\u001b[2m:\u001b[0m\u001b]8;id=594534;file:///workspaces/heartkit/heartkit/tasks/denoise/export.py#104\u001b\\\u001b[2m104\u001b[0m\u001b]8;;\u001b\\\n"
+ "\u001b[34mINFO \u001b[0m \u001b[1m[\u001b[0mTFL METRICS\u001b[1m]\u001b[0m \u001b[33mMAE\u001b[0m=\u001b[1;36m0\u001b[0m\u001b[1;36m.0814\u001b[0m \u001b[33mMSE\u001b[0m=\u001b[1;36m0\u001b[0m\u001b[1;36m.0169\u001b[0m \u001b[33mCOS\u001b[0m=\u001b[1;36m0\u001b[0m\u001b[1;36m.8313\u001b[0m \u001b[33mSNR\u001b[0m=\u001b[1;36m16\u001b[0m\u001b[1;36m.2097\u001b[0m \u001b]8;id=881444;file:///tmp/ipykernel_1619872/776805021.py\u001b\\\u001b[2m776805021.py\u001b[0m\u001b]8;;\u001b\\\u001b[2m:\u001b[0m\u001b]8;id=427708;file:///tmp/ipykernel_1619872/776805021.py#4\u001b\\\u001b[2m4\u001b[0m\u001b]8;;\u001b\\\n"
]
},
"metadata": {},
@@ -1078,116 +1337,57 @@
}
],
"source": [
- "# TF dumps a lot of info to stdout, so we redirect it to /dev/null\n",
- "with open(os.devnull, 'w') as devnull:\n",
- " with contextlib.redirect_stdout(devnull), contextlib.redirect_stderr(devnull):\n",
- " task.export(export_params)\n"
+ "tf_rst = nse.metrics.compute_metrics(metrics, y_true, y_pred_tf)\n",
+ "tfl_rst = nse.metrics.compute_metrics(metrics, y_true, y_pred_tfl)\n",
+ "logger.info(\"[TF METRICS] \" + \" \".join([f\"{k.upper()}={v:.4f}\" for k, v in tf_rst.items()]))\n",
+ "logger.info(\"[TFL METRICS] \" + \" \".join([f\"{k.upper()}={v:.4f}\" for k, v in tfl_rst.items()]))"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
- "## Run inference demo\n",
+ "## ECG Denoising Demo\n",
"\n",
- "We will run a demo on the PC to verify that the model is working as expected. The demo will load the model, run inferences across a randomly selected ECG signal, and generate a interactive report. The report will provide the original, noisy, and denoised ECG signals for comparison. "
+ "Finally, we will demonstrate how to use the trained ECG denoiser model to remove noise and artifacts from raw ECG signals. We will load a sample ECG signal, add noise to it, and then denoise it using the trained model. We will visualize the original, noisy, and denoised ECG signals to compare the results."
]
},
{
"cell_type": "code",
- "execution_count": 22,
+ "execution_count": 25,
"metadata": {},
- "outputs": [],
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "\u001b[1m1/1\u001b[0m \u001b[32m━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[37m\u001b[0m \u001b[1m1s\u001b[0m 729ms/step\n"
+ ]
+ }
+ ],
"source": [
- "demo_params = hk.HKDemoParams(\n",
- " job_dir=job_dir, # Directory to store all output artifacts\n",
- " datasets=[datasets[0]], # Datasets to demo on\n",
- " sampling_rate=sampling_rate, # Target sampling rate\n",
- " frame_size=frame_size, # Target frame size\n",
- " preprocesses=preprocesses, # Preprocessing pipeline\n",
- " augmentations=augmentations, # Augmentation pipeline\n",
- " model_file=model_file, # Model file to load\n",
- " # Demo params\n",
- " threshold=0.5, # Threshold for classification\n",
- " demo_size=500, # Number of samples to demo (8 sec)\n",
- " backend=\"pc\", # Backend to use\n",
- " display_report=True, # Display a report\n",
- ")"
+ "sample_idx = np.random.randint(0, len(test_x))\n",
+ "ecg = test_y[sample_idx].squeeze()\n",
+ "aug_ecg = test_x[sample_idx].squeeze()\n",
+ "clean_ecg = model.predict(np.reshape(aug_ecg, (1, -1, 1)))\n",
+ "snr = nse.metrics.Snr()\n",
+ "snr.update_state(ecg.reshape(1, -1, 1), aug_ecg.reshape(1, -1, 1))\n",
+ "aug_snr = snr.result().numpy()\n",
+ "snr.reset_state()\n",
+ "snr.update_state(ecg.reshape(1, -1, 1), clean_ecg.reshape(1, -1, 1))\n",
+ "clean_snr = snr.result().numpy()"
]
},
{
"cell_type": "code",
- "execution_count": 54,
+ "execution_count": 26,
"metadata": {},
"outputs": [
- {
- "name": "stderr",
- "output_type": "stream",
- "text": [
- "Inference: 100%|██████████| 2/2 [00:00<00:00, 5.42it/s]\n",
- "Inference: 100%|██████████| 2/2 [00:00<00:00, 48.44it/s]\n",
- "Inference: 100%|██████████| 2/2 [00:00<00:00, 49.64it/s]\n",
- "Inference: 100%|██████████| 2/2 [00:00<00:00, 43.49it/s]\n",
- "Inference: 100%|██████████| 2/2 [00:00<00:00, 45.52it/s]\n"
- ]
- },
- {
- "data": {
- "text/html": [
- " \n",
- " "
- ]
- },
- "metadata": {},
- "output_type": "display_data"
- },
{
"data": {
- "text/html": [
- ""
+ "image/png": "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",
+ "text/plain": [
+ ""
]
},
"metadata": {},
@@ -1195,15 +1395,29 @@
}
],
"source": [
- "pio.renderers.default = \"notebook\"\n",
- "task.demo(params=demo_params)"
+ "fig, ax = plt.subplots(3, 1, figsize=(9, 5), sharex=True)\n",
+ "ax[0].plot(ts, ecg.squeeze(), color=plot_theme.primary_color, lw=3)\n",
+ "ax[1].plot(ts, aug_ecg.squeeze(), color=plot_theme.secondary_color, lw=3)\n",
+ "ax[2].plot(ts, clean_ecg.squeeze(), color=plot_theme.tertiary_color, lw=3)\n",
+ "\n",
+ "ax[0].set_ylabel(\"Reference\")\n",
+ "ax[1].set_ylabel(\"Noisy\")\n",
+ "ax[2].set_ylabel(\"Denoised\")\n",
+ "\n",
+ "ax[1].text(0.98, 0.15, f\"{aug_snr:4.02f} dB SNR\", transform=ax[1].transAxes, ha=\"right\", va=\"top\", weight='bold')\n",
+ "ax[2].text(0.98, 0.15, f\"{clean_snr:4.02f} dB SNR\", transform=ax[2].transAxes, ha=\"right\", va=\"top\", weight='bold')\n",
+ "# Disable y-axis ticks for all plots\n",
+ "for axes in ax:\n",
+ " axes.yaxis.set_ticks([])\n",
+ "ax[-1].set_xlabel(\"Time (s)\")\n",
+ "fig.suptitle(\"ECG Denoising Demo\")\n",
+ "fig.tight_layout()\n",
+ "fig.show()"
]
},
{
- "cell_type": "code",
- "execution_count": null,
+ "cell_type": "markdown",
"metadata": {},
- "outputs": [],
"source": []
}
],
diff --git a/docs/guides/train-ecg-segmentation.ipynb b/docs/guides/train-ecg-segmentation.ipynb
index 78f4695e..b69525fc 100644
--- a/docs/guides/train-ecg-segmentation.ipynb
+++ b/docs/guides/train-ecg-segmentation.ipynb
@@ -151,8 +151,8 @@
"source": [
"datasets = [\n",
" dict(\n",
- " name=\"synthetic\",\n",
- " path=datasets_dir / \"synthetic\",\n",
+ " name=\"ecg-synthetic\",\n",
+ " path=datasets_dir / \"ecg-synthetic\",\n",
" params=dict(\n",
" num_pts=num_synthetic_patients,\n",
" params=dict(\n",
diff --git a/docs/index.md b/docs/index.md
index 835b0aa4..2f0b44f4 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -12,7 +12,7 @@
---
-Introducing HeartKit, an AI Development Kit (ADK) that enables developers to easily train and deploy real-time __heart-monitoring__ models onto [Ambiq's family of ultra-low power SoCs](https://ambiq.com/soc/). The kit provides a variety of datasets, efficient model architectures, and heart-related tasks. In addition, HeartKit provides optimization and deployment routines to generate efficient inference models. Finally, the kit includes a number of pre-trained models and task-level demos to showcase the capabilities.
+Introducing HeartKit, an AI Development Kit (ADK) that enables developers to easily train and deploy real-time __heart-monitoring__ models onto [Ambiq's family of ultra-low power SoCs](https://ambiq.com/soc/). The kit provides a variety of datasets, efficient model architectures, and heart-related tasks out of the box. In addition, HeartKit provides optimization and deployment routines to generate efficient inference models. Finally, the kit includes a number of pre-trained models and task-level demos to showcase the capabilities.
**Key Features:**
@@ -30,29 +30,34 @@ Please explore the HeartKit Docs, a comprehensive resource designed to help you
- **Tasks** `HeartKit` provides tasks like rhythm, segment, and denoising [:material-magnify-expand: Explore Tasks](tasks/index.md){ .md-button }
- **Datasets** Several built-in datasets can be leveraged [:material-database-outline: Explore Datasets](./datasets/index.md){ .md-button }
- **Model Zoo** Pre-trained models are available for each task [:material-download: Explore Models](./zoo/index.md){ .md-button }
+- **Guides** Detailed guides on tasks, models, and datasets [:material-book-open-page-variant: Explore Guides](./guides/index.md){ .md-button }
## Installation
-To get started, first install the local python package `heartkit` along with its dependencies via `pip` or `Poetry`:
-
-=== "Poetry install"
+To get started, first install the python package `heartkit` along with its dependencies via `Git` or `PyPi`:
+=== "PyPI install"
+
```console
- $ poetry install .
+ $ pip install heartkit
---> 100%
```
-=== "Pip install"
-
+=== "Git clone"
+
```console
- $ pip install heartkit
+ $ git clone https://github.com/AmbiqAI/heartkit.git
+ Cloning into 'heartkit'...
+ Resolving deltas: 100% (3491/3491), done.
+ $ cd heartkit
+ $ poetry install
---> 100%
```
@@ -63,45 +68,38 @@ To get started, first install the local python package `heartkit` along with its
##
Usage
-__HeartKit__ can be used as either a CLI-based tool or as a Python package to perform advanced development. In both forms, HeartKit exposes a number of modes and tasks outlined below. In addition, by leveraging highly-customizable configurations, HeartKit can be used to create custom workflows for a given application with minimal coding. Refer to the [Quickstart](./quickstart.md) to quickly get up and running in minutes.
+__HeartKit__ can be used as either a CLI-based tool or as a Python package to perform advanced development. In both forms, HeartKit exposes a number of modes and tasks outlined below. In addition, by leveraging highly-customizable configurations and extendable factories, HeartKit can be used to create custom workflows for a given application with minimal coding. Refer to the [Quickstart](./quickstart.md) to quickly get up and running in minutes.
---
-##
Modes
+##
[Tasks](./tasks/index.md)
-__HeartKit__ provides a number of [modes](./modes/index.md) that can be invoked for a given task. These modes can be accessed via the CLI or directly from the `task` within the Python package.
+__HeartKit__ includes a number of built-in [tasks](./tasks/index.md). Each task provides reference routines for training, evaluating, and exporting the model. The routines can be customized by providing highly flexibile configuration files/objects. Additionally, new tasks can be added to the __HeartKit__ framework by defining a new [Task class](./tasks/byot.md) and registering it to the [__Task Factory__](./tasks/byot.md).
-- **[Download](./modes/download.md)**: Download specified datasets
-- **[Train](./modes/train.md)**: Train a model for specified task and datasets
-- **[Evaluate](./modes/evaluate.md)**: Evaluate a model for specified task and datasets
-- **[Export](./modes/export.md)**: Export a trained model to TensorFlow Lite and TFLM
-- **[Demo](./modes/demo.md)**: Run task-level demo on PC or remotely on Ambiq EVB
-
----
-
-##
Task Factory
-
-__HeartKit__ includes a number of built-in [tasks](./tasks/index.md). Each task provides reference routines for training, evaluating, and exporting the model. The routines can be customized by providing a configuration file or by setting the parameters directly in the code. Additional tasks can be easily added to the __HeartKit__ framework by creating a new task class and registering it to the __task factory__.
-
-- **[Denoise](./tasks/denoise.md)**: Denoise ECG signal
-- **[Segmentation](./tasks/segmentation.md)**: Perform ECG based segmentation (P-Wave, QRS, T-Wave)
+- **[Denoise](./tasks/denoise.md)**: Remove noise and artifacts from ECG signals
+- **[Segmentation](./tasks/segmentation.md)**: Perform ECG/PPG based segmentation
- **[Rhythm](./tasks/rhythm.md)**: Heart rhythm classification (AFIB, AFL)
- **[Beat](./tasks/beat.md)**: Beat-level classification (NORM, PAC, PVC, NOISE)
-- **[BYOT](./tasks/byot.md)**: Bring-Your-Own-Task (BYOT) to create custom tasks
+- **[Bring-Your-Own-Task (BYOT)](./tasks/byot.md)**: Create and register custom tasks
---
-##
Model Factory
+##
[Modes](./modes/index.md)
-__HeartKit__ provides a __model factory__ that allows you to easily create and train customized models. The model factory includes a number of modern networks well suited for efficient, real-time edge applications. Each model architecture exposes a number of high-level parameters that can be used to customize the network for a given application. These parameters can be set as part of the configuration accessible via the CLI and Python package. Check out the [Model Factory Guide](./models/index.md) to learn more about the available network architectures.
+__HeartKit__ provides a number of [modes](./modes/index.md) that can be invoked for a given task. These modes can be accessed via the CLI or directly from a [Task](./tasks/index.md) within code. Each mode is accompanied by a set of [task parameters](./modes/configuration.md#hktaskparams) that can be customized to fit the user's needs.
----
+- **[Download](./modes/download.md)**: Download specified datasets
+- **[Train](./modes/train.md)**: Train a model for specified task and datasets
+- **[Evaluate](./modes/evaluate.md)**: Evaluate a model for specified task and datasets
+- **[Export](./modes/export.md)**: Export a trained model to TensorFlow Lite and TFLM
+- **[Demo](./modes/demo.md)**: Run task-level demo on PC or remotely on Ambiq EVB
-##
Dataset Factory
+---
-__HeartKit__ exposes several open-source datasets for training each of the HeartKit tasks via the __dataset factory__. For certain tasks, we also provide synthetic data provided by [PhysioKit](https://ambiqai.github.io/physiokit) to help improve model generalization. Each dataset has a corresponding Python class to aid in downloading and generating data for the given task. Additional datasets can be added to the HeartKit framework by creating a new dataset class and registering it to the dataset factory. Check out the [Dataset Factory Guide](./datasets/index.md) to learn more about the available datasets along with their corresponding licenses and limitations.
+##
[Datasets](./datasets/index.md)
+The ADK includes several built-in [datasets](./datasets/index.md) for training __heart-monitoring__ related tasks. We also provide synthetic dataset generators for signals such as ECG, PPG, and RSP along with segmentation and fiducials. Each included dataset inherits from [HKDataset](./datasets/dataset.md) that provides consistent interface for downloading and accessing the data. Additional datasets can be added to the HeartKit framework by creating a new dataset class and registering it to the dataset factory, DatasetFactory. Check out the [Datasets Guide](./datasets/index.md) to learn more about the available datasets along with their corresponding licenses and limitations.
* **[Icentia11k](./datasets/icentia11k.md)**: 11-lead ECG data collected from 11,000 subjects captured continously over two weeks.
* **[LUDB](./datasets/ludb.md)**: 200 ten-second 12-lead ECG records w/ annotated P-wave, QRS, and T-wave boundaries.
@@ -109,12 +107,35 @@ __HeartKit__ exposes several open-source datasets for training each of the Heart
* **[LSAD](./datasets/lsad.md)**: 10-second, 12-lead ECG dataset collected from 45,152 subjects w/ over 100 scp codes.
* **[PTB-XL](./datasets/ptbxl.md)**: 10-second, 12-lead ECG dataset collected from 18,885 subjects w/ 72 different diagnostic classes.
* **[Synthetic](./datasets/synthetic.md)**: A synthetic dataset generator provided by [PhysioKit](https://ambiqai.github.io/physiokit).
-* **[BYOD](./datasets/byod.md)**: Bring-Your-Own-Dataset (BYOD) to add additional datasets.
+* **[Bring-Your-Own-Dataset (BYOD)](./datasets/byod.md)**: Add and register new datasets to the framework.
+
+---
+
+##
[Models](./models/index.md)
+
+__HeartKit__ provides a variety of model architectures geared towards efficient, real-time edge applications. These models are provided by Ambiq's [neuralspot-edge](https://ambiqai.github.io/neuralspot-edge/) and expose a set of parameters that can be used to fully customize the network for a given application. In addition, HeartKit includes a model factory, [ModelFactory](./models/index.md#model-factory) to register current models as well as allow new custom architectures to be added. Check out the [Models Guide](./models/index.md) to learn more about the available network architectures and model factory.
+
+- **[TCN](https://ambiqai.github.io/neuralspot-edge/models/tcn)**: A CNN leveraging dilated convolutions (key=`tcn`)
+- **[U-Net](https://ambiqai.github.io/neuralspot-edge/models/unet)**: A CNN with encoder-decoder architecture for segmentation tasks (key=`unet`)
+- **[U-NeXt](https://ambiqai.github.io/neuralspot-edge/models/unext)**: A U-Net variant leveraging MBConv blocks (key=`unext`)
+- **[EfficientNetV2](https://ambiqai.github.io/neuralspot-edge/models/efficientnet)**: A CNN leveraging MBConv blocks (key=`efficientnet`)
+- **[MobileOne](https://ambiqai.github.io/neuralspot-edge/models/mobileone)**: A CNN aimed at sub-1ms inference (key=`mobileone`)
+- **[ResNet](https://ambiqai.github.io/neuralspot-edge/models/resnet)**: A popular CNN often used for vision tasks (key=`resnet`)
+- **[Conformer](https://ambiqai.github.io/neuralspot-edge/models/conformer)**: A transformer composed of both convolutional and self-attention blocks (key=`conformer`)
+- **[MetaFormer](https://ambiqai.github.io/neuralspot-edge/models/metaformer)**: A transformer composed of both spatial mixing and channel mixing blocks (key=`metaformer`)
+- **[TSMixer](https://ambiqai.github.io/neuralspot-edge/models/tsmixer)**: An All-MLP Architecture for Time Series Classification (key=`tsmixer`)
+- **[Bring-Your-Own-Model (BYOM)](https://ambiqai.github.io/neuralspot-edge/models/byom)**: Register new SoTA model architectures w/ custom configurations
+
+---
+
+##
[Model Zoo](./zoo/index.md)
+
+The ADK includes a number of pre-trained models and configurationn recipes for the built-in tasks. These models are trained on a variety of datasets and are optimized for deployment on Ambiq's ultra-low power SoCs. In addition to providing links to download the models, __HeartKit__ provides the corresponding configuration files and performance metrics. The configuration files allow you to easily recreate the models or use them as a starting point for custom solutions. Furthermore, the performance metrics provide insights into the trade-offs between model complexity and performance. Check out the [Model Zoo](./zoo/index.md) to learn more about the available models and their corresponding performance metrics.
---
-##
Model Zoo
+##
[Guides](./guides/index.md)
-A number of pre-trained models are available for each task. These models are trained on a variety of datasets and are optimized for deployment on Ambiq's ultra-low power SoCs. In addition to providing links to download the models, __HeartKit__ provides the corresponding configuration files and performance metrics. The configuration files allow you to easily recreate the models or use them as a starting point for custom solutions. Furthermore, the performance metrics provide insights into the model's accuracy, precision, recall, and F1 score. For a number of the models, we provide experimental and ablation studies to showcase the impact of various design choices. Check out the [Model Zoo](./zoo/index.md) to learn more about the available models and their corresponding performance metrics. Also explore the [Experiments](./experiments/index.md) to learn more about the ablation studies and experimental results.
+Checkout the [Guides](./guides/index.md) to see detailed examples and tutorials on how to use HeartKit for a variety of tasks. The guides provide step-by-step instructions on how to train, evaluate, and deploy models for a given task. In addition, the guides provide insights into the design choices and performance metrics for the models. The guides are designed to help you get up and running quickly and to provide a deeper understanding of the models and tasks available in HeartKit.
---
diff --git a/docs/models/byom.md b/docs/models/byom.md
new file mode 100644
index 00000000..98f54026
--- /dev/null
+++ b/docs/models/byom.md
@@ -0,0 +1,88 @@
+# Bring-Your-Own-Model (BYOM)
+
+The model factory can be extended to include custom models. This is useful when you have a custom model architecture that you would like to use for training. The custom model can be registered with the model factory by defining a custom model function and registering it with the `ModelFactory`.
+
+##
How it Works
+
+1. **Create a Model**: Define a new model function that takes a `keras.Input`, model parameters, and number of classes as arguments and returns a `keras.Model`.
+
+ ```python
+
+ import keras
+ import heartkit as hk
+
+ def custom_model_from_object(
+ x: keras.KerasTensor,
+ params: dict,
+ num_classes: int | None = None,
+ ) -> keras.Model:
+
+ y = x
+ # Create fully connected network from params
+ for layer in params["layers"]:
+ y = keras.layers.Dense(layer["units"], activation=layer["activation"])(y)
+
+ if num_classes:
+ y = keras.layers.Dense(num_classes, activation="softmax")(y)
+
+ return keras.Model(inputs=x, outputs=y)
+ ```
+
+2. **Register the Model**: Register the new model function with the `ModelFactory` by calling the `register` method. This method takes the model name and the callable as arguments.
+
+ ```python
+ hk.ModelFactory.register("custom-model", custom_model_from_object)
+ ```
+
+3. **Use the Model**: The new model can now be used with the `ModelFactory` to perform various operations such as downloading and generating data.
+
+ ```python
+ inputs = keras.Input(shape=(100,))
+ model = hk.ModelFactory.get("custom-model")(
+ x=inputs,
+ params={
+ "layers": [
+ {"units": 64, "activation": "relu"},
+ {"units": 32, "activation": "relu"},
+ ]
+ },
+ num_classes=5,
+ )
+
+ model.summary()
+
+ ```
+
+##
Better Model Params
+
+Rather than using a dictionary to define the model parameters, you can define a custom dataclass or [Pydantic](https://pydantic-docs.helpmanual.io/) model to enforce type checking and provide better documentation.
+
+```python
+from pydantic import BaseModel
+
+class CustomLayerParams(BaseModel):
+ units: int
+ activation: str
+
+class CustomModelParams(BaseModel):
+ layers: list[CustomLayerParams]
+
+def custom_model_from_object(
+ x: keras.KerasTensor,
+ params: dict,
+ num_classes: int | None = None,
+) -> keras.Model:
+
+ # Convert and validate params
+ params = CustomModelParams(**params)
+
+ y = x
+ # Create fully connected network from params
+ for layer in params.layers:
+ y = keras.layers.Dense(layer.units, activation=layer.activation)(y)
+
+ if num_classes:
+ y = keras.layers.Dense(num_classes, activation="softmax")(y)
+
+ return keras.Model(inputs=x, outputs=y)
+```
diff --git a/docs/models/index.md b/docs/models/index.md
index 2b56b52c..33ff78cc 100644
--- a/docs/models/index.md
+++ b/docs/models/index.md
@@ -1,67 +1,114 @@
-# :factory: Model Factory
+# :material-graph-outline: Models
-HeartKit provides a model factory that allows you to easily create and train customized models via [KerasEdge](). KerasEdge includes a growing number of state-of-the-art models that can be easily configured and trained using high-level parameters. The models are designed to be efficient and well-suited for real-time edge applications. Most of the models are based on state-of-the-art architectures that have been modified to allow for more fine-grain customization. The also support 1D variants to allow for training on time-series data. The included models are well suited for efficient, real-time edge applications.
+HeartKit provides a number of model architectures that can be used for training __heart-monitoring tasks__. While a number of off-the-shelf models exist, they are often not efficient nor optimized for real-time, edge applications. To address this, HeartKit provides a model factory that allows you to easily create and train customized models via [neuralspot-edge](https://ambiqai.github.io/neuralspot-edge/). `neuralspot-edge` includes a growing number of state-of-the-art models that can be easily configured and trained using high-level parameters. The models are designed to be efficient and well-suited for real-time, edge applications. Most of the models are based on state-of-the-art architectures that have been modified to allow for more fine-grain customization. In addition, the models support 1D variants to allow for training on time-series data. Please check [neuralspot-edge](https://ambiqai.github.io/neuralspot-edge/) for list of available models and their configurations.
-Please check [KerasEdge]() for list of available models and their configurations.
+---
+
+##
Available Models
+
+- **[TCN](https://ambiqai.github.io/neuralspot-edge/models/tcn)**: A CNN leveraging dilated convolutions (key=`tcn`)
+- **[U-Net](https://ambiqai.github.io/neuralspot-edge/models/unet)**: A CNN with encoder-decoder architecture for segmentation tasks (key=`unet`)
+- **[U-NeXt](https://ambiqai.github.io/neuralspot-edge/models/unext)**: A U-Net variant leveraging MBConv blocks (key=`unext`)
+- **[EfficientNetV2](https://ambiqai.github.io/neuralspot-edge/models/efficientnet)**: A CNN leveraging MBConv blocks (key=`efficientnet`)
+- **[MobileOne](https://ambiqai.github.io/neuralspot-edge/models/mobileone)**: A CNN aimed at sub-1ms inference (key=`mobileone`)
+- **[ResNet](https://ambiqai.github.io/neuralspot-edge/models/resnet)**: A popular CNN often used for vision tasks (key=`resnet`)
+- **[Conformer](https://ambiqai.github.io/neuralspot-edge/models/conformer)**: A transformer composed of both convolutional and self-attention blocks (key=`conformer`)
+- **[MetaFormer](https://ambiqai.github.io/neuralspot-edge/models/metaformer)**: A transformer composed of both spatial mixing and channel mixing blocks (key=`metaformer`)
+- **[TSMixer](https://ambiqai.github.io/neuralspot-edge/models/tsmixer)**: An All-MLP Architecture for Time Series Classification (key=`tsmixer`)
+* **[Bring-Your-Own-Model](./byom.md)**: Add a custom model architecture to HeartKit.
+
+---
+
+##
Model Factory
+
+HeartKit includes a model factory, `ModelFactory`, that eases the processes of creating models for training. The factory allows you to create models by specifying the model key and the model parameters. The factory will then create the model using the specified parameters. The factory also allows you to register custom models that can be used for training. By leveraring a factory, a task only needs to provide the architecture key and the parameters, and the factory will take care of the rest.
+
+The model factory provides the following methods:
+
+* **hk.ModelFactory.register**: Register a custom model
+* **hk.ModelFactory.unregister**: Unregister a custom model
+* **hk.ModelFactory.has**: Check if a model is registered
+* **hk.ModelFactory.get**: Get a model from the factory
+* **hk.ModelFactory.list**: List all available models
---
##
Usage
-The model factory can be invoked either via CLI or within the `heartkit` python package. At a high level, the model factory performs the following actions based on the provided configuration parameters:
-
-!!! Example
-
- === "JSON"
-
- ```json
- {
- "name": "tcn",
- "params": {
- "input_kernel": [1, 3],
- "input_norm": "batch",
- "blocks": [
- {"depth": 1, "branch": 1, "filters": 12, "kernel": [1, 3], "dilation": [1, 1], "dropout": 0.10, "ex_ratio": 1, "se_ratio": 0, "norm": "batch"},
- {"depth": 1, "branch": 1, "filters": 20, "kernel": [1, 3], "dilation": [1, 1], "dropout": 0.10, "ex_ratio": 1, "se_ratio": 2, "norm": "batch"},
- {"depth": 1, "branch": 1, "filters": 28, "kernel": [1, 3], "dilation": [1, 2], "dropout": 0.10, "ex_ratio": 1, "se_ratio": 2, "norm": "batch"},
- {"depth": 1, "branch": 1, "filters": 36, "kernel": [1, 3], "dilation": [1, 4], "dropout": 0.10, "ex_ratio": 1, "se_ratio": 2, "norm": "batch"},
- {"depth": 1, "branch": 1, "filters": 40, "kernel": [1, 3], "dilation": [1, 8], "dropout": 0.10, "ex_ratio": 1, "se_ratio": 2, "norm": "batch"}
- ],
- "output_kernel": [1, 3],
- "include_top": true,
- "use_logits": true,
- "model_name": "tcn"
- }
+### Defining a model in configuration file
+
+A model can be created when invoking a command via the CLI by setting [architecture](../modes/configuration.md#hktaskparams) in the configuration file. The task will use the supplied name to get the registered model and instantiate with the provided parameters.
+
+Given the following configuration file `configuration.json`:
+
+```json
+{
+ ...
+ "architecture:" {
+ "name": "tcn",
+ "params": {
+ "input_kernel": [1, 3],
+ "input_norm": "batch",
+ "blocks": [
+ {"depth": 1, "branch": 1, "filters": 12, "kernel": [1, 3], "dilation": [1, 1], "dropout": 0.10, "ex_ratio": 1, "se_ratio": 0, "norm": "batch"},
+ {"depth": 1, "branch": 1, "filters": 20, "kernel": [1, 3], "dilation": [1, 1], "dropout": 0.10, "ex_ratio": 1, "se_ratio": 2, "norm": "batch"},
+ {"depth": 1, "branch": 1, "filters": 28, "kernel": [1, 3], "dilation": [1, 2], "dropout": 0.10, "ex_ratio": 1, "se_ratio": 2, "norm": "batch"},
+ {"depth": 1, "branch": 1, "filters": 36, "kernel": [1, 3], "dilation": [1, 4], "dropout": 0.10, "ex_ratio": 1, "se_ratio": 2, "norm": "batch"},
+ {"depth": 1, "branch": 1, "filters": 40, "kernel": [1, 3], "dilation": [1, 8], "dropout": 0.10, "ex_ratio": 1, "se_ratio": 2, "norm": "batch"}
+ ],
+ "output_kernel": [1, 3],
+ "include_top": true,
+ "use_logits": true,
+ "model_name": "tcn"
}
- ```
-
- === "Python"
-
- ```python
- import keras
- from heartkit.models import Tcn, TcnParams, TcnBlockParams
-
- inputs = keras.Input(shape=(800, 1))
- num_classes = 5
-
- model = Tcn(
- x=inputs,
- params=TcnParams(
- input_kernel=(1, 3),
- input_norm="batch",
- blocks=[
- TcnBlockParams(filters=8, kernel=(1, 3), dilation=(1, 1), dropout=0.1, ex_ratio=1, se_ratio=0, norm="batch"),
- TcnBlockParams(filters=16, kernel=(1, 3), dilation=(1, 2), dropout=0.1, ex_ratio=1, se_ratio=0, norm="batch"),
- TcnBlockParams(filters=24, kernel=(1, 3), dilation=(1, 4), dropout=0.1, ex_ratio=1, se_ratio=4, norm="batch"),
- TcnBlockParams(filters=32, kernel=(1, 3), dilation=(1, 8), dropout=0.1, ex_ratio=1, se_ratio=4, norm="batch"),
- ],
- output_kernel=(1, 3),
- include_top=True,
- use_logits=True,
- model_name="tcn",
- ),
- num_classes=num_classes,
- )
- ```
+ }
+}
+```
+
+### Defining a model in code
+
+The model can be created using the following command:
+
+```bash
+heartkit --mode train --task rhythm --config config.json
+```
+
+Alternatively, the model can be created directly in code using the following snippet:
+
+```python
+
+import keras
+import heartkit as hk
+
+architecture = {
+ "name": "tcn",
+ "params": {
+ "input_kernel": [1, 3],
+ "input_norm": "batch",
+ "blocks": [
+ {"depth": 1, "branch": 1, "filters": 12, "kernel": [1, 3], "dilation": [1, 1], "dropout": 0.10, "ex_ratio": 1, "se_ratio": 0, "norm": "batch"},
+ {"depth": 1, "branch": 1, "filters": 20, "kernel": [1, 3], "dilation": [1, 1], "dropout": 0.10, "ex_ratio": 1, "se_ratio": 2, "norm": "batch"},
+ {"depth": 1, "branch": 1, "filters": 28, "kernel": [1, 3], "dilation": [1, 2], "dropout": 0.10, "ex_ratio": 1, "se_ratio": 2, "norm": "batch"},
+ {"depth": 1, "branch": 1, "filters": 36, "kernel": [1, 3], "dilation": [1, 4], "dropout": 0.10, "ex_ratio": 1, "se_ratio": 2, "norm": "batch"},
+ {"depth": 1, "branch": 1, "filters": 40, "kernel": [1, 3], "dilation": [1, 8], "dropout": 0.10, "ex_ratio": 1, "se_ratio": 2, "norm": "batch"}
+ ],
+ "output_kernel": [1, 3],
+ "include_top": True,
+ "use_logits": True,
+ "model_name": "tcn"
+ }
+}
+
+inputs = keras.Input(shape=(256,1), dtype="float32")
+num_classes = 5
+
+model = hk.ModelFactory.get(architecture["name"])(
+ x=inputs,
+ params=architecture["params"],
+ num_classes=num_classes,
+)
+
+model.summary()
+```
---
diff --git a/docs/modes/configuration.md b/docs/modes/configuration.md
index 39a0fa7d..52855473 100644
--- a/docs/modes/configuration.md
+++ b/docs/modes/configuration.md
@@ -2,176 +2,94 @@
For each mode, a set of parameters are required to run the task. The following sections provide details on the parameters required for each mode.
-### QuantizationParams
+##
QuantizationParams
+
+Quantization parameters define the quantization-aware training (QAT) and post-training quantization (PTQ) settings. This is used for modes: train, evaluate, export, and demo.
| Argument | Type | Opt/Req | Default | Description |
| --- | --- | --- | --- | --- |
| enabled | bool | Optional | False | Enable quantization |
| qat | bool | Optional | False | Enable quantization aware training (QAT) |
-| format | QuantizationType | Optional | INT8 | Quantization mode |
+| format | Literal["int8", "int16", "float16"] | Optional | int8 | Quantization mode |
| io_type | str | Optional | int8 | I/O type |
-| conversion | ConversionType | Optional | KERAS | Conversion method |
+| conversion | Literal["keras", "tflite"] | Optional | keras | Conversion method |
| debug | bool | Optional | False | Debug quantization |
| fallback | bool | Optional | False | Fallback to float32 |
+##
NamedParams
-### ModelArchitecture
-
-| Argument | Type | Opt/Req | Default | Description |
-| --- | --- | --- | --- | --- |
-| name | str | Required | | Model architecture name |
-| params | dict[str, Any] | Optional | {} | Model architecture parameters |
-
-### PreprocessParams
+Named parameters are used to provide custom parameters for a given object or callable. For example, a dataset, 'my-dataset', may require custom parameters such as 'path', 'label', 'sampling_rate', etc. When a task loads the dataset using `name`, the task will then unpack the custom parameters and pass them to the dataset loader.
| Argument | Type | Opt/Req | Default | Description |
| --- | --- | --- | --- | --- |
-| name | str | Required | | Preprocess name |
-| params | dict[str, Any] | Optional | {} | Preprocess parameters |
+| name | str | Required | | Named parameters name |
+| params | dict[str, Any] | Optional | {} | Named parameters |
-### AugmentationParams
-
-| Argument | Type | Opt/Req | Default | Description |
-| --- | --- | --- | --- | --- |
-| name | str | Required | | Augmentation name |
-| params | dict[str, Any] | Optional | {} | Augmentation parameters |
+##
HKDownloadParams
-### DatasetParams
-
-| Argument | Type | Opt/Req | Default | Description |
-| --- | --- | --- | --- | --- |
-| name | str | Required | | Dataset name |
-| path | Path | Optional | Path() | Dataset path |
-| params | dict[str, Any] | Optional | {} | Parameters |
-| weight | float | Optional | 1 | Dataset weight |
-
-
-### HKDownloadParams
+These parameters are used by `download` mode to download all supplied datasets.
| Argument | Type | Opt/Req | Default | Description |
| --- | --- | --- | --- | --- |
| job_dir | Path | Optional | `tempfile.gettempdir` | Job output directory |
-| datasets | list[DatasetParams] | Optional | | Datasets |
+| datasets | list[NamedParams] | Optional | | Datasets |
| progress | bool | Optional | True | Display progress bar |
| force | bool | Optional | False | Force download dataset- overriding existing files |
| data_parallelism | int | Optional | `os.cpu_count` | # of data loaders running in parallel |
-### HKTrainParams
+
+##
HKTaskParams
+
+These parameters are supplied to a [Task](../tasks/index.md) when running a given mode such as `train`, `evaluate`, `export`, or `demo`. A single configuration object is used to simplify configuration files and heavy re-use of parameters between modes.
+
| Argument | Type | Opt/Req | Default | Description |
| --- | --- | --- | --- | --- |
| name | str | Required | experiment | Experiment name |
| project | str | Required | heartkit | Project name |
| job_dir | Path | Optional | `tempfile.gettempdir` | Job output directory |
-| datasets | list[DatasetParams] | Optional | | Datasets |
+| datasets | list[NamedParams] | Optional | | Datasets |
+| dataset_weights | list[float]\|None | Optional | None | Dataset weights |
| sampling_rate | int | Optional | 250 | Target sampling rate (Hz) |
-| frame_size | int | Optional | 1250 | Frame size |
-| num_classes | int | Optional | 1 | # of classes |
-| class_map | dict[int, int] | Optional | | Class/label mapping |
-| class_names | list[str] | Optional | None | Class names |
+| frame_size | int | Optional | 1250 | Frame size in samples |
| samples_per_patient | int\|list[int] | Optional | 1000 | # train samples per patient |
| val_samples_per_patient | int\|list[int] | Optional | 1000 | # validation samples per patient |
+| test_samples_per_patient | int\|list[int] | Optional | 1000 | # test samples per patient |
| train_patients | float\|None | Optional | None | # or proportion of patients for training |
| val_patients | float\|None | Optional | None | # or proportion of patients for validation |
+| test_patients | float\|None | Optional | None | # or proportion of patients for testing |
| val_file | Path\|None | Optional | None | Path to load/store pickled validation file |
+| test_file | Path\|None | Optional | None | Path to load/store pickled test file |
| val_size | int\|None | Optional | None | # samples for validation |
+| test_size | int | Optional | 10000 | # samples for testing |
+| num_classes | int | Optional | 1 | # of classes |
+| class_map | dict[int, int] | Optional | | Class/label mapping |
+| class_names | list[str]\|None | Optional | None | Class names |
| resume | bool | Optional | False | Resume training |
-| architecture | ModelArchitecture | Optional | | Custom model architecture |
-| model_file | Path\|None | Optional | None | Path to save model file (.keras) |
-| threshold | float\|None | Optional | None | Model output threshold |
-| weights_file | Path\|None | Optional | None | Path to a checkpoint weights to load |
+| architecture | NamedParams\|None | Optional | None | Custom model architecture |
+| model_file | Path\|None | Optional | None | Path to load/save model file (.keras) |
+| use_logits | bool | Optional | True | Use logits output or softmax |
+| weights_file | Path\|None | Optional | None | Path to a checkpoint weights to load/save |
| quantization | QuantizationParams | Optional | | Quantization parameters |
| lr_rate | float | Optional | 0.001 | Learning rate |
| lr_cycles | int | Optional | 3 | Number of learning rate cycles |
| lr_decay | float | Optional | 0.9 | Learning rate decay |
-| class_weights | Literal["balanced", "fixed"] | Optional | fixed | Class weights |
| label_smoothing | float | Optional | 0 | Label smoothing |
| batch_size | int | Optional | 32 | Batch size |
-| buffer_size | int | Optional | 100 | Buffer size |
+| buffer_size | int | Optional | 100 | Buffer cache size |
| epochs | int | Optional | 50 | Number of epochs |
| steps_per_epoch | int | Optional | 10 | Number of steps per epoch |
+| val_steps_per_epoch | int | Optional | 10 | Number of validation steps |
| val_metric | Literal["loss", "acc", "f1"] | Optional | loss | Performance metric |
-| preprocesses | list[PreprocessParams] | Optional | | Preprocesses |
-| augmentations | list[AugmentationParams] | Optional | | Augmentations |
-| seed | int\|None | Optional | None | Random state seed |
-| data_parallelism | int | Optional | `os.cpu_count` | # of data loaders running in parallel |
-| verbose | int | Optional | 1 | Verbosity level |
-
-### HKTestParams
-
-| Argument | Type | Opt/Req | Default | Description |
-| --- | --- | --- | --- | --- |
-| name | str | Required | experiment | Experiment name |
-| project | str | Required | heartkit | Project name |
-| job_dir | Path | Optional | `tempfile.gettempdir` | Job output directory |
-| datasets | list[DatasetParams] | Optional | | Datasets |
-| sampling_rate | int | Optional | 250 | Target sampling rate (Hz) |
-| frame_size | int | Optional | 1250 | Frame size |
-| num_classes | int | Optional | 1 | # of classes |
-| class_map | dict[int, int] | Optional | | Class/label mapping |
-| class_names | list[str] | Optional | None | Class names |
-| test_samples_per_patient | int\|list[int] | Optional | 1000 | # test samples per patient |
-| test_patients | float\|None | Optional | None | # or proportion of patients for testing |
-| test_size | int | Optional | 200000 | # samples for testing |
-| test_file | Path\|None | Optional | None | Path to load/store pickled test file |
-| preprocesses | list[PreprocessParams] | Optional | | Preprocesses |
-| augmentations | list[AugmentationParams] | Optional | | Augmentations |
-| model_file | Path\|None | Optional | None | Path to save model file (.keras) |
-| threshold | float\|None | Optional | None | Model output threshold |
-| seed | int\|None | Optional | None | Random state seed |
-| data_parallelism | int | Optional | `os.cpu_count` | # of data loaders running in parallel |
-| verbose | int | Optional | 1 | Verbosity level |
-
-### HKExportParams
-
-| Argument | Type | Opt/Req | Default | Description |
-| --- | --- | --- | --- | --- |
-| name | str | Required | experiment | Experiment name |
-| project | str | Required | heartkit | Project name |
-| job_dir | Path | Optional | `tempfile.gettempdir` | Job output directory |
-| datasets | list[DatasetParams] | Optional | | Datasets |
-| sampling_rate | int | Optional | 250 | Target sampling rate (Hz) |
-| frame_size | int | Optional | 1250 | Frame size |
-| num_classes | int | Optional | 3 | # of classes |
-| class_map | dict[int, int] | Optional | | Class/label mapping |
-| class_names | list[str] | Optional | None | Class names |
-| test_samples_per_patient | int\|list[int] | Optional | 100 | # test samples per patient |
-| test_patients | float\|None | Optional | None | # or proportion of patients for testing |
-| test_size | int | Optional | 100000 | # samples for testing |
-| test_file | Path\|None | Optional | None | Path to load/store pickled test file |
-| preprocesses | list[PreprocessParams] | Optional | | Preprocesses |
-| augmentations | list[AugmentationParams] | Optional | | Augmentations |
-| model_file | Path\|None | Optional | None | Path to save model file (.keras) |
+| class_weights | Literal["balanced", "fixed"] | Optional | fixed | Class weights |
| threshold | float\|None | Optional | None | Model output threshold |
-| val_acc_threshold | float\|None | Optional | 0.98 | Validation accuracy threshold |
-| use_logits | bool | Optional | True | Use logits output or softmax |
-| quantization | QuantizationParams | Optional | | Quantization parameters |
+| val_metric_threshold | float\|None | Optional | 0.98 | Validation metric threshold |
| tflm_var_name | str | Optional | g_model | TFLite Micro C variable name |
| tflm_file | Path\|None | Optional | None | Path to copy TFLM header file (e.g. ./model_buffer.h) |
-| data_parallelism | int | Optional | `os.cpu_count` | # of data loaders running in parallel |
-| model_config | ConfigDict | Optional | | Model configuration |
-| verbose | int | Optional | 1 | Verbosity level |
-
-### HKDemoParams
-
-| Argument | Type | Opt/Req | Default | Description |
-| --- | --- | --- | --- | --- |
-| name | str | Required | experiment | Experiment name |
-| project | str | Required | heartkit | Project name |
-| job_dir | Path | Optional | `tempfile.gettempdir` | Job output directory |
-| datasets | list[DatasetParams] | Optional | | Datasets |
-| sampling_rate | int | Optional | 250 | Target sampling rate (Hz) |
-| frame_size | int | Optional | 1250 | Frame size |
-| num_classes | int | Optional | 1 | # of classes |
-| class_map | dict[int, int] | Optional | | Class/label mapping |
-| class_names | list[str] | Optional | None | Class names |
-| preprocesses | list[PreprocessParams] | Optional | | Preprocesses |
-| augmentations | list[AugmentationParams] | Optional | | Augmentations |
-| model_file | Path\|None | Optional | None | Path to save model file (.keras) |
| backend | str | Optional | pc | Backend |
-| demo_size | int | Optional | 1000 | # samples for demo |
+| demo_size | int\|None | Optional | 1000 | # samples for demo |
| display_report | bool | Optional | True | Display report |
| seed | int\|None | Optional | None | Random state seed |
-| model_config | ConfigDict | Optional | | Model configuration |
+| data_parallelism | int | Optional | `os.cpu_count` | # of data loaders running in parallel |
| verbose | int | Optional | 1 | Verbosity level |
diff --git a/docs/modes/demo.md b/docs/modes/demo.md
index 2f0f0a65..d0ad2f96 100644
--- a/docs/modes/demo.md
+++ b/docs/modes/demo.md
@@ -4,7 +4,7 @@
Each task in HeartKit has a corresponding demo mode that allows you to run a task-level demonstration using the specified backend inference engine (e.g. PC or EVB). This is useful to showcase the model's performance in real-time and to verify its accuracy in a real-world scenario. Similar to other modes, the demo can be invoked either via CLI or within `heartkit` python package. At a high level, the demo mode performs the following actions based on the provided configuration parameters:
-1. Load the configuration file (e.g. `segmentation-class-2`)
+1. Load the configuration file (e.g. `configuration.json`)
1. Load the desired dataset features (e.g. `icentia11k`)
1. Load the trained model (e.g. `model.keras`)
1. Load random test subject's data
@@ -15,44 +15,63 @@ Each task in HeartKit has a corresponding demo mode that allows you to run a tas
##
Inference Backends
-HeartKit includes two built-in backend inference engines: PC and EVB. Additional backends can be easily added to the HeartKit framework by creating a new backend class and registering it to the backend factory.
+HeartKit includes two built-in backend inference engines: PC and EVB. Additional backends can be easily added to the HeartKit framework by creating a new backend class and registering it to the backend factory, `BackendFactory`.
-### PC Backend
+### PC Backend Inference Engine
-The PC backend is used to run the task-level demo on the local machine. This is useful for quick testing and debugging of the model.
+The PC backend is used to run the task-level demo on the local machine via `Keras`. This is useful for quick testing and debugging of the model.
-1. Create / modify configuration file (e.g. `segmentation-class-2.json`)
+1. Create / modify configuration file (e.g. `configuration.json`)
1. Ensure "pc" is selected as the backend in configuration file.
-1. Run demo `heartkit --mode demo --task segmentation --config ./configs/segmentation-class-2.json`
+1. Run demo `heartkit --mode demo --task segmentation --config ./configuration.json`
1. HTML report will be saved to `${job_dir}/report.html`
-### EVB Backend
+### EVB Backend Inference Engine
-The EVB backend is used to run the task-level demo on an Ambiq EVB. This is useful to showcase the model's performance in real-time and to verify its accuracy in a real-world scenario.
+The EVB backend is used to run the task-level demo on an Ambiq EVB. This is useful to showcase the model's performance in real-time and to verify its accuracy on deployed hardware.
-1. Create / modify configuration file (e.g. `segmentation-class-2.json`)
-1. Ensure "evb" is selected as the backend in configuration file.
+1. Create / modify configuration file (e.g. `configuration.json`)
+1. Ensure "evb" is selected as the `backend` in configuration file.
1. Plug EVB into PC via two USB-C cables.
1. Build and flash firmware to EVB `cd evb && make && make deploy`
-1. Run demo `heartkit --mode demo --task beat --config ./configs/segmentation-class-2.json`
+1. Run demo `heartkit --mode demo --task beat --config ./configuration.json`
1. HTML report will be saved to `${job_dir}/report.html`
### Bring-Your-Own-Backend
-Similar to datasets, tasks, and models, the demo mode can be customized to use your own backend inference engine. HeartKit includes a backend factory (`BackendFactory`) that is used to create and run the backend engine.
+Similar to datasets, dataloaders, tasks, and models, the demo mode can be customized to use your own backend inference engine. HeartKit includes a backend factory (`BackendFactory`) that is used to create and run the backend engine.
#### How it Works
-1. **Create a Backend**: Define a new backend by creating a new Python file. The file should contain a class that inherits from the `DemoBackend` base class and implements the required methods.
+1. **Create a Backend**: Define a new backend class that inherits from the `HKInferenceBackend` base class and implements the required abstract methods.
```python
import heartkit as hk
- class CustomBackend(hk.HKBackend):
- def __init__(self, config):
- super().__init__(config)
+ class CustomBackend(hk.HKInferenceBackend):
+ """Custom backend inference engine"""
- def run(self, model, data):
+ def __init__(self, params: hk.HKTaskParams) -> None:
+ self.params = params
+
+ def open(self):
+ """Open backend"""
+ pass
+
+ def close(self):
+ """Close backend"""
+ pass
+
+ def set_inputs(self, inputs: npt.NDArray):
+ """Set inputs"""
+ pass
+
+ def perform_inference(self):
+ """Perform inference"""
+ pass
+
+ def get_outputs(self) -> npt.NDArray:
+ """Get outputs"""
pass
```
@@ -89,7 +108,7 @@ The following is an example of a task-level demo report for the segmentation tas
=== "CLI"
```bash
- heartkit -m export -t segmentation -c ./configs/segmentation-class-2.json
+ heartkit -m export -t segmentation -c ./configuration.json
```
=== "Python"
@@ -105,6 +124,6 @@ The following is an example of a task-level demo report for the segmentation tas
##
Arguments
-Please refer to [HKDemoParams](../modes/configuration.md#hkdemoparams) for the list of arguments that can be used with the `demo` command.
+Please refer to [HKTaskParams](../modes/configuration.md) for the list of arguments that can be used with the `demi` command.
---
diff --git a/docs/modes/download.md b/docs/modes/download.md
index c7118dd1..55aa6142 100644
--- a/docs/modes/download.md
+++ b/docs/modes/download.md
@@ -1,24 +1,49 @@
# Download Datasets
+##
Introduction
+
The `download` command is used to download all datasets specified. Please refer to [Datasets](../datasets/index.md) for details on the available datasets. Additional datasets can be added by creating a new dataset class and registering it with __HeartKit__ dataset factory.
##
Usage
-!!! Example
+### CLI
+
+Using the CLI, the `download` command can be used to download specified datasets in the configuration file or directly in the command line.
+
+```bash
+heartkit -m download -c '{"datasets": [{"name": "ptbxl", "parameters": {"path": ".datatasets/ptbxl"}}]}'
+```
+
+### Python
+
+Using HeartKit in Python, the `download` method can be used for a specific dataset.
- The following command will download and prepare four datasets.
+```python
+import heartkit as hk
- === "CLI"
+ds = hk.DatasetFactory.get("ptbxl")(path=".datasets/ptbxl")
+ds.download()
+```
- ```bash
- heartkit -m download -c ./configs/download-datasets.json
- # ^ No task is required
- ```
+To download multiple datasets, the high-level `download_datasets` function can be used.
- === "Python"
+```python
+import heartkit as hk
- --8<-- "assets/modes/python-download-snippet.md"
+params = hk.HKDownloadParams(
+ ds_path="./datasets",
+ datasets=[hk.NamedParams(
+ name="ptbxl",
+ parameters={"path": ".datasets/ptbxl"}
+ ), hk.NamedParams(
+ name="lsad",
+ parameters={"path": ".datasets/lsad"}
+ )]
+ progress=True
+)
+hk.datasets.download_datasets(params)
+```
##
Arguments
diff --git a/docs/modes/evaluate.md b/docs/modes/evaluate.md
index af22cde1..7e777743 100644
--- a/docs/modes/evaluate.md
+++ b/docs/modes/evaluate.md
@@ -4,26 +4,51 @@
Evaluate mode is used to test the performance of the model on the reserved test set for the specified task. Similar to training, the routine can be customized via CLI configuration file or by setting the parameters directly in the code. The evaluation process involves testing the model's performance on the test data to measure its accuracy, precision, recall, and F1 score. A number of results and metrics will be generated and saved to the `job_dir`.
+
+
+
+1. Load the configuration data (e.g. `configuration.json`) (1)
+1. Load the desired datasets and task-specific dataloaders (e.g. `icentia11k`)
+1. Load the trained model
+1. Evaluate the model
+1. Generate evaluation report
+
+
+
+1. Configuration parameters:
+--8<-- "assets/usage/json-configuration.md"
+
---
##
Usage
-!!! Example
+### CLI
+
+The following command will evaluate a rhythm model using the reference configuration.
+
+```bash
+heartkit --task rhythm --mode evaluate --config ./configuration.json
+```
+
+### Python
+
+The model can be evaluated using the following snippet:
+
+```python
- The following command will evaluate the rhythm model using the reference configuration:
+task = hk.TaskFactory.get("rhythm")
- === "CLI"
+params = hk.HKTaskParams(...) # (1)
- ```bash
- heartkit --mode evaluate --task rhythm --config ./configs/rhythm-class-2.json
- ```
+task.evaluate(params)
- === "Python"
+```
- --8<-- "assets/modes/python-evaluate-snippet.md"
+1. Configuration parameters:
+--8<-- "assets/usage/python-configuration.md"
---
##
Arguments
-Please refer to [HKTestParams](../modes/configuration.md#hktestparams) for the list of arguments that can be used with the `evaluate` command.
+Please refer to [HKTaskParams](../modes/configuration.md#hktaskparams) for the list of arguments that can be used with the `evaluate` command.
diff --git a/docs/modes/export.md b/docs/modes/export.md
index 0853a290..0c0864c3 100644
--- a/docs/modes/export.md
+++ b/docs/modes/export.md
@@ -4,25 +4,51 @@
Export mode is used to convert the trained TensorFlow model into a format that can be used for deployment onto Ambiq's family of SoCs. Currently, the command will convert the TensorFlow model into both TensorFlow Lite (TFL) and TensorFlow Lite for micro-controller (TFLM) variants. The command will also verify the models' outputs match. The activations and weights can be quantized by configuring the `quantization` section in the configuration file or by setting the `quantization` parameter in the code.
+
+
+1. Load the configuration data (e.g. `configuration.json`) (1)
+1. Load the desired datasets and task-specific dataloaders (e.g. `icentia11k`)
+1. Load the trained model
+1. Convert the model (e.g. TFL, TFLM)
+1. Verify the models' outputs match
+1. Save the converted model
+
+
+
+1. Configuration parameters:
+--8<-- "assets/usage/json-configuration.md"
+
---
+
##
Usage
-!!! Example
+### CLI
+
+The following command will export a rhythm model using the reference configuration.
+
+```bash
+heartkit --task rhythm --mode export --config ./configuration.json
+```
+
+### Python
+
+The model can be evaluated using the following snippet:
+
+```python
- The following command will export the rhythm model to TF Lite and TFLM:
+task = hk.TaskFactory.get("rhythm")
- === "CLI"
+params = hk.HKTaskParams(...) # (1)
- ```bash
- heartkit --mode export --task rhythm --config ./configs/rhythm-class-2.json
- ```
+task.export(params)
- === "Python"
+```
- --8<-- "assets/modes/python-export-snippet.md"
+1. Configuration parameters:
+--8<-- "assets/usage/python-configuration.md"
---
##
Arguments
-Please refer to [HKExportParams](../modes/configuration.md#hkexportparams) for the list of arguments that can be used with the `evaluate` command.
+Please refer to [HKTaskParams](../modes/configuration.md#hktaskparams) for the list of arguments that can be used with the `export` command.
diff --git a/docs/modes/index.md b/docs/modes/index.md
index 7f8993be..e05e5690 100644
--- a/docs/modes/index.md
+++ b/docs/modes/index.md
@@ -1,12 +1,14 @@
-# HeartKit Modes
+# HeartKit Task Modes
##
Introduction
Rather than offering a handful of static models, HeartKit provides a complete framework designed to cover the entire design process of creating customized ML models well-suited for low-power, wearable applications. Each mode serves a specific purpose and is engineered to offer you the flexibility and efficiency required for different tasks and use-cases.
+Besides `download`, each `Task` implementes routines for each of the modes: `train`, `evaluate`, `export`, and `demo`. These modes are designed to streamline the process of training, evaluating, exporting, and running task-level demonstrations on the trained models.
+
---
-## Available Modes
+##
Available Modes
- **[Download](./download.md)**: Download specified datasets
- **[Train](./train.md)**: Train a model for specified task and datasets
diff --git a/docs/modes/train.md b/docs/modes/train.md
index 4dc546c4..f58c00a7 100644
--- a/docs/modes/train.md
+++ b/docs/modes/train.md
@@ -2,38 +2,57 @@
##
Introduction
-Each task provides a mode to train a model on the specified datasets. The training mode can be invoked either via CLI or within `heartkit` python package. At a high level, the training mode performs the following actions based on the provided configuration parameters:
+Each task provides a mode to train a model on the specified datasets and dataloaders. The training mode can be invoked either via CLI or within `heartkit` python package. At a high level, the training mode performs the following actions based on the provided configuration parameters:
-1. Load the configuration data (e.g. `rhythm-class-2.json`)
-1. Load the desired datasets (e.g. `icentia11k`)
+
+
+1. Load the configuration data (e.g. `configuration.json`) (1)
+1. Load the desired datasets and task-specific dataloaders (e.g. `icentia11k`)
1. Load the custom model architecture (e.g. `tcn`)
1. Train the model
1. Save the trained model
1. Generate training report
+
+
+1. Configuration parameters:
+--8<-- "assets/usage/json-configuration.md"
+
---
##
Usage
-!!! Example
+### CLI
+
+The following command will train a rhythm model using the reference configuration.
+
+```bash
+heartkit --task rhythm --mode train --config ./configuration.json
+```
+
+### Python
+
+The model can be trained using the following snippet:
+
+```python
+
+task = hk.TaskFactory.get("rhythm")
- The following command will train a rhythm model using the reference configuration:
+params = hk.HKTaskParams(...) # (1)
- === "CLI"
+task.train(params)
- ```bash
- heartkit --task rhythm --mode train --config ./configs/rhythm-class-2.json
- ```
+```
- === "Python"
+1. Configuration parameters:
+--8<-- "assets/usage/python-configuration.md"
- --8<-- "assets/modes/python-train-snippet.md"
---
##
Arguments
-Please refer to [HKTrainParams](../modes/configuration.md#hktrainparams) for the list of arguments that can be used with the `train` command.
+Please refer to [HKTaskParams](../modes/configuration.md#hktaskparams) for the list of arguments that can be used with the `train` command.
---
diff --git a/docs/overrides/main.html b/docs/overrides/main.html
new file mode 100644
index 00000000..702c96bf
--- /dev/null
+++ b/docs/overrides/main.html
@@ -0,0 +1,11 @@
+{% extends "base.html" %}
+
+{% block content %}
+{% if page.nb_url %}
+
+ {% include ".icons/material/download.svg" %}
+
+{% endif %}
+
+{{ super() }}
+{% endblock content %}
diff --git a/docs/quickstart.md b/docs/quickstart.md
index b83734b4..416c3612 100644
--- a/docs/quickstart.md
+++ b/docs/quickstart.md
@@ -25,24 +25,30 @@ We provide several installation methods including pip, poetry, and Docker. Insta
When using editable mode via Poetry, be sure to activate the python environment: `poetry shell`.
On Windows using Powershell, use `.venv\Scripts\activate.ps1`.
- === "Pip/Poetry install"
+ === "PyPI install"
Install the HeartKit package using pip or Poetry.
Visit the Python Package Index (PyPI) for more details on the package: [https://pypi.org/project/heartkit/](https://pypi.org/project/heartkit/)
+ ```bash
+ # Install with pip
+ pip install heartkit
+ ```
+
+ Or, if you prefer to use Poetry, you can install the package with the following command:
```bash
# Install with poetry
poetry add heartkit
```
- Or, if you prefer to use Pip, you can install the package with the following command:
+ Alternatively, you can install the latest development version directly from the GitHub repository. Make sure to have the Git command-line tool installed on your system. The @main command installs the main branch and may be modified to another branch, i.e. @canary.
```bash
- # Install with pip
- pip install heartkit
+ pip install git+https://github.com/AmbiqAI/heartkit.git@main
```
- Alternatively, you can install the latest development version directly from the GitHub repository. Make sure to have the Git command-line tool installed on your system. The @main command installs the main branch and may be modified to another branch, i.e. @release.
+
+ Or, using Poetry:
```bash
poetry add git+https://github.com/AmbiqAI/heartkit.git@main
@@ -65,7 +71,7 @@ Once installed, __HeartKit__ can be used as either a CLI-based tool or as a Pyth
##
Use HeartKit with CLI
-The HeartKit command line interface (CLI) allows for simple single-line commands without the need for a Python environment. The CLI requires no customization or Python code. You can simply run all tasks from the terminal with the __heartkit__ command. Check out the [CLI Guide](./usage/cli.md) to learn more about available options.
+The HeartKit command line interface (CLI) allows for simple single-line commands without the need for a Python environment. The CLI requires no customization or Python code. You can simply run all the built-in tasks from the terminal with the __heartkit__ command. Check out the [CLI Guide](./usage/cli.md) to learn more about available options.
!!! example
@@ -92,35 +98,35 @@ The HeartKit command line interface (CLI) allows for simple single-line commands
Download datasets specified in the configuration file.
```bash
- heartkit -m download -c ./configs/download-datasets.json
+ heartkit -m download -c ./download-datasets.json
```
=== "Train"
Train a rhythm model using the supplied configuration file.
```bash
- heartkit -m train -t rhythm -c ./configs/rhythm-class-2.json
+ heartkit -m train -t rhythm -c ./configuration.json
```
=== "Evaluate"
Evaluate the trained rhythm model using the supplied configuration file.
```bash
- heartkit -m evaluate -t rhythm -c ./configs/rhythm-class-2.json
+ heartkit -m evaluate -t rhythm -c ./configuration.json
```
=== "Demo"
Run demo on trained rhythm model using the supplied configuration file.
```bash
- heartkit -m demo -t rhythm -c ./configs/rhythm-class-2.json
+ heartkit -m demo -t rhythm -c ./configuration.json
```
##
Use HeartKit with Python
-The __HeartKit__ Python package allows for more fine-grained control and customization. You can use the package to train, evaluate, and deploy models for a variety of tasks. The package is designed to be simple and easy to use.
+The __HeartKit__ Python package allows for more fine-grained control and customization. You can use the package to train, evaluate, and deploy models for a variety of tasks. You can create custom datasets, models, and tasks and register them with corresponding factories and use them like built-in tasks.
-For example, you can create a custom model, train it, evaluate its performance on a validation set, and even export a quantized TensorFlow Lite model for deployment. Check out the [Python Guide](./usage/python.md) to learn more about using HeartKit as a Python package.
+For example, you can create a custom task, train it, evaluate its performance on a validation set, and even export a quantized TensorFlow Lite model for deployment. Check out the [Python Guide](./usage/python.md) to learn more about using HeartKit as a Python package.
!!! Example
@@ -129,32 +135,31 @@ For example, you can create a custom model, train it, evaluate its performance o
ds_params = hk.HKDownloadParams(
ds_path="./datasets",
- datasets=["ludb", "synthetic"],
+ datasets=["ludb", "ecg-synthetic"],
progress=True
)
-
- with open("configuration.json", "r", encoding="utf-8") as file:
- config = json.load(file)
-
- train_params = hk.HKTrainParams.model_validate(config)
- test_params = hk.HKTestParams.model_validate(config)
- export_params = hk.HKExportParams.model_validate(config)
-
# Download datasets
hk.datasets.download_datasets(ds_params)
+ # Generate task parameters from configuration
+ params = hk.HKTaskParams(...) # Expand to see example (1)
+
task = hk.TaskFactory.get("rhythm")
# Train rhythm model
- task.train(train_params)
+ task.train(params)
# Evaluate rhythm model
- task.evaluate(test_params)
+ task.evaluate(params)
# Export rhythm model
- task.export(export_params)
+ task.export(params)
```
+ 1. Configuration parameters:
+ --8<-- "assets/usage/python-configuration.md"
+
+
---
diff --git a/docs/tasks/beat.md b/docs/tasks/beat.md
index 9cb36dd8..6148a6f0 100644
--- a/docs/tasks/beat.md
+++ b/docs/tasks/beat.md
@@ -12,10 +12,19 @@ In beat classification, we classify individual beats as either normal or abnorma
##
Characteristics
-| | Atrial | Junctional | Ventricular |
+| | Atrial | Junctional | Ventricular |
| --- | --- | --- | --- |
-| Premature | __PAC__
P-wave: Different
QRS: Narrow (normal)
Aberrated: LBBB or RBBB | __PJC__
P-wave: None / retrograde
QRS: Narrow (normal)
Compensatory SA Pause | __PVC__
P-wave: None
QRS: Wide (> 120 ms)
Compensatory SA PauseEscape |
-| Atrial Escape | P-wave: Abnormal
QRS: Narrow (normal)
Ventricular rate: < 60 bpm
Junctional Escape
| P-wave: None
QRS: Narrow (normal)
Bradycardia (40-60 bpm)
Ventricular Escape | P-wave: None
QRS: Wide
Bradycardia (< 40 bpm) |
+| Premature | __PAC__
P-wave: Different
QRS: Narrow (normal)
Aberrated: LBBB or RBBB | __PJC__
P-wave: None / retrograde
QRS: Narrow (normal)
Compensatory SA Pause | __PVC__
P-wave: None
QRS: Wide (> 120 ms)
Compensatory SA Pause |
+| Escape | Atrial Escape | P-wave: Abnormal
QRS: Narrow (normal)
Ventricular rate: < 60 bpm
Junctional Escape
| P-wave: None
QRS: Narrow (normal)
Bradycardia (40-60 bpm)
Ventricular Escape | P-wave: None
QRS: Wide
Bradycardia (< 40 bpm) |
+
+---
+
+##
Dataloaders
+
+Dataloaders are available for the following datasets:
+
+* **[Icentia11k](../datasets/icentia11k.md)**
+* **[PTB-XL](../datasets/ptbxl.md)**
---
diff --git a/docs/tasks/denoise.md b/docs/tasks/denoise.md
index 74a89d16..6313fd0d 100644
--- a/docs/tasks/denoise.md
+++ b/docs/tasks/denoise.md
@@ -1,4 +1,4 @@
-# Signal Denoising
+# Signal Denoising Task
##
Overview
@@ -31,6 +31,17 @@ The following table summarizes the characteristics of common noise sources in PP
---
+##
Dataloaders
+
+Dataloaders are available for the following datasets:
+
+* **[LUDB](../datasets/ludb.md)**
+* **[PTB-XL](../datasets/ptbxl.md)**
+* **[ECG Synthetic](../datasets/synthetic.md)**
+* **[PPG Synthetic](../datasets/synthetic.md)**
+
+---
+
##
Pre-trained Models
The following table provides the latest performance and accuracy results of denoising models. Additional result details can be found in [Model Zoo → Denoise](../zoo/denoise.md).
diff --git a/docs/tasks/index.md b/docs/tasks/index.md
index c3c85705..1b702f87 100644
--- a/docs/tasks/index.md
+++ b/docs/tasks/index.md
@@ -2,25 +2,25 @@
##
Introduction
-HeartKit provides several built-in __heart-monitoring__ related tasks. Each task is designed to address a unique aspect such as ECG denoising, segmentation, and rhythm/beat classification. The tasks are designed to be modular and can be used independently or in combination to address specific use cases. In addition to the built-in tasks, custom tasks can be created by extending the `HKTask` base class and registering it with the task factory.
+HeartKit provides several built-in __heart-monitoring__ tasks. Each task is designed to address a unique aspect such as ECG denoising, segmentation, and rhythm/beat classification. The tasks are designed to be modular and can be used independently or in combination to address specific use cases. In addition to the built-in tasks, custom tasks can be created by extending the `HKTask` base class and registering it with the task factory.
##
Available Tasks
###
[Denoise](./denoise.md)
-ECG denoising is the process of removing noise from an ECG signal. This task is useful for improving the quality of the ECG signal and for further downstream tasks such as segmentation.
+[Signal denoise](./denoise.md) is the process of removing noise from an ECG signal. This task is useful for improving the quality of the ECG signal and for further downstream tasks such as segmentation.
###
[Segmentation](./segmentation.md)
-ECG segmentation is the process of delineating an ECG signal into individual waves (e.g. P-wave, QRS, T-wave). This task is useful for extracting features (e.g. HRV) from the ECG signal and for further analysis such as rhythm classification.
+[Signal segmentation](./segmentation.md) is the process of delineating a signal into its constituent parts. In the context of ECG, segmentation refers to delineating the ECG signal into individual waves (e.g. P-wave, QRS, T-wave). This task is useful for extracting features (e.g. HRV) from the ECG signal and for further analysis such as rhythm classification.
###
[Rhythm](./rhythm.md)
-Rhythm classification is the process of identifying abnormal heart rhythms, also known as arrhythmias, such as atrial fibrillation (AFIB) and atrial flutter (AFL). Cardiovascular diseases such as AFIB are a leading cause of morbidity and mortality worldwide. Being able to remotely identify heart arrhtyhmias is important for early detection and intervention.
+[Rhythm classification](./rhythm.md) is the process of identifying abnormal heart rhythms, also known as arrhythmias, such as atrial fibrillation (AFIB) and atrial flutter (AFL). Cardiovascular diseases such as AFIB are a leading cause of morbidity and mortality worldwide. Being able to remotely identify heart arrhtyhmias is important for early detection and intervention.
###
[Beat](./beat.md)
-Beat classification is the process of identifying and classifying individual heart beats such as normal, premature, and escape beats. By identifying abnormal heart beats, it is possible to detect and monitor various heart conditions.
+[Beat classification](./beat.md) is the process of identifying and classifying individual heart beats such as normal, premature, and escape beats. By identifying abnormal heart beats, it is possible to detect and monitor various heart conditions.
+--- -->
diff --git a/docs/zoo/denoise.md b/docs/zoo/denoise.md
index 21a3800c..8d45ae06 100644
--- a/docs/zoo/denoise.md
+++ b/docs/zoo/denoise.md
@@ -68,15 +68,15 @@ The following table provides the latest pre-trained models for ECG denoising. Be
| MSE | 4.4% |
| COSSIM | 97.4% |
-##
EVB Performance
+
-##
EVB Performance
+
diff --git a/docs/zoo/index.md b/docs/zoo/index.md
index d137d89f..bc5e1cc7 100644
--- a/docs/zoo/index.md
+++ b/docs/zoo/index.md
@@ -37,3 +37,20 @@ The following table provides the latest performance and accuracy results for bea
The following table provides the latest performance and accuracy results for multi-label diagnostic classification models. Additional result details can be found in [Zoo → Diagnostic](./diagnostic.md).
--8<-- "assets/zoo/diagnostic/diagnostic-model-zoo-table.md" -->
+
+
+##
Reproducing results
+
+Each pre-trained model has a corresponding `configuration.json` file that can be used to reproduce the model and results.
+
+To reproduce a pre-trained rhythm model with configuration file `configuration.json`, run the following command:
+
+```bash
+heartkit -m train -t rhythm -c configuration.json
+```
+
+To evaluate the trained rhythm model with configuration file `configuration.json`, run the following command:
+
+```bash
+heartkit -m evaluate -t rhythm -c configuration.json
+```
diff --git a/docs/zoo/rhythm.md b/docs/zoo/rhythm.md
index 23c1e715..89a23862 100644
--- a/docs/zoo/rhythm.md
+++ b/docs/zoo/rhythm.md
@@ -89,7 +89,7 @@ The following table provides the latest pre-trained models for rhythm classifica
---
-##
EVB Performance
+
---
-
-
diff --git a/heartkit/__init__.py b/heartkit/__init__.py
index 4fe8ad03..84cd5a69 100644
--- a/heartkit/__init__.py
+++ b/heartkit/__init__.py
@@ -1,26 +1,22 @@
import os
from importlib.metadata import version
-from . import cli, datasets, metrics, models, rpc, tasks
-from .datasets import DatasetFactory, HKDataset
+from . import cli, datasets, models, rpc, tasks
+from .datasets import DatasetFactory, HKDataset, HKDataloader
from .defines import (
- AugmentationParams,
QuantizationParams,
- DatasetParams,
- HKDemoParams,
HKDownloadParams,
- HKExportParams,
+ HKTaskParams,
HKMode,
- HKTestParams,
- HKTrainParams,
- PreprocessParams,
+ NamedParams,
)
from .models import ModelFactory
from .tasks import HKBeat, HKRhythm, HKSegment, HKTask, TaskFactory
-from .utils import setup_logger, silence_tensorflow
+from .rpc import BackendFactory
+import neuralspot_edge as nse
__version__ = version(__name__)
if "TF_CPP_MIN_LOG_LEVEL" not in os.environ:
os.environ["TF_CPP_MIN_LOG_LEVEL"] = "2"
-setup_logger(__name__)
+nse.utils.setup_logger(__name__)
diff --git a/heartkit/cli.py b/heartkit/cli.py
index 24b47a9e..b12d14a1 100644
--- a/heartkit/cli.py
+++ b/heartkit/cli.py
@@ -3,20 +3,14 @@
from argdantic import ArgField, ArgParser
from pydantic import BaseModel
+import neuralspot_edge as nse
from .datasets import download_datasets
-from .defines import (
- HKDemoParams,
- HKDownloadParams,
- HKExportParams,
- HKMode,
- HKTestParams,
- HKTrainParams,
-)
+from .defines import HKDownloadParams, HKMode, HKTaskParams
from .tasks import TaskFactory
-from .utils import setup_logger
-logger = setup_logger(__name__)
+
+logger = nse.utils.setup_logger(__name__)
cli = ArgParser()
@@ -60,18 +54,19 @@ def _run(
task_handler = TaskFactory.get(task)
+ params = parse_content(HKTaskParams, config)
match mode:
case HKMode.train:
- task_handler.train(parse_content(HKTrainParams, config))
+ task_handler.train(params)
case HKMode.evaluate:
- task_handler.evaluate(parse_content(HKTestParams, config))
+ task_handler.evaluate(params)
case HKMode.export:
- task_handler.export(parse_content(HKExportParams, config))
+ task_handler.export(params)
case HKMode.demo:
- task_handler.demo(parse_content(HKDemoParams, config))
+ task_handler.demo(params)
case _:
logger.error("Error: Unknown command")
diff --git a/heartkit/datasets/__init__.py b/heartkit/datasets/__init__.py
index b22d290f..44ad596b 100644
--- a/heartkit/datasets/__init__.py
+++ b/heartkit/datasets/__init__.py
@@ -1,31 +1,25 @@
-from .augmentation import augment_pipeline
+from .augmentation import create_augmentation_pipeline
from .bidmc import BidmcDataset
from .dataset import HKDataset
-from .defines import PatientGenerator, Preprocessor
+from .defines import PatientGenerator
from .download import download_datasets
-from .icentia11k import IcentiaDataset
-from .lsad import LsadDataset
-from .ludb import LudbDataset
+from .dataloader import HKDataloader
+from .icentia11k import IcentiaDataset, IcentiaBeat, IcentiaRhythm
+from .icentia_mini import IcentiaMiniDataset, IcentiaMiniRhythm, IcentiaMiniBeat
+from .lsad import LsadDataset, LsadScpCode
+from .ludb import LudbDataset, LudbSegmentation
from .nstdb import NstdbNoise
-from .preprocessing import preprocess_pipeline
-from .ptbxl import PtbxlDataset
+from .ptbxl import PtbxlDataset, PtbxlScpCode
from .qtdb import QtdbDataset
-from .synthetic import SyntheticDataset
-from .syntheticppg import SyntheticPpgDataset
-from .utils import (
- create_dataset_from_data,
- create_interleaved_dataset_from_generator,
- random_id_generator,
- uniform_id_generator,
-)
-from ..utils import create_factory
-
-DatasetFactory = create_factory(factory="HKDatasetFactory", type=HKDataset)
+from .ecg_synthetic import EcgSyntheticDataset
+from .ppg_synthetic import PpgSyntheticDataset
+from .factory import DatasetFactory
DatasetFactory.register("bidmc", BidmcDataset)
-DatasetFactory.register("synthetic", SyntheticDataset)
-DatasetFactory.register("syntheticppg", SyntheticPpgDataset)
+DatasetFactory.register("ecg-synthetic", EcgSyntheticDataset)
+DatasetFactory.register("ppg-synthetic", PpgSyntheticDataset)
DatasetFactory.register("icentia11k", IcentiaDataset)
+DatasetFactory.register("icentia_mini", IcentiaMiniDataset)
DatasetFactory.register("lsad", LsadDataset)
DatasetFactory.register("ludb", LudbDataset)
DatasetFactory.register("qtdb", QtdbDataset)
@@ -39,5 +33,6 @@
"LudbDataset",
"PtbxlDataset",
"QtdbDataset",
- "SyntheticDataset",
+ "EcgSyntheticDataset",
+ "NstdbNoise",
]
diff --git a/heartkit/datasets/augmentation.py b/heartkit/datasets/augmentation.py
index 02ac0720..c13c00b9 100644
--- a/heartkit/datasets/augmentation.py
+++ b/heartkit/datasets/augmentation.py
@@ -1,124 +1,106 @@
+import keras
import numpy as np
-import numpy.typing as npt
-import physiokit as pk
+import neuralspot_edge as nse
-from ..defines import AugmentationParams
+from ..defines import NamedParams
from .nstdb import NstdbNoise
-_nstdb_glb: NstdbNoise | None = None
+def create_augmentation_layer(augmentation: NamedParams, sampling_rate: int) -> keras.Layer:
+ """Create an augmentation layer from a configuration
+
+ Args:
+ augmentation (NamedParams): Augmentation configuration
+ sampling_rate (int): Sampling rate of the data
+
+ Returns:
+ keras.Layer: Augmentation layer
+
+ Example:
+
+ ```python
+ import heartkit as hk
+ x = keras.random.normal
+ layer = hk.datasets.augmentation.create_augmentation_layer(
+ hk.NamedParams(name="random_noise", params={"factor": 0.01}),
+ sampling_rate=100
+ )
+ y = layer(x)
+ ```
+ """
+ match augmentation.name:
+ case "amplitude_warp":
+ return nse.layers.preprocessing.AmplitudeWarp(sample_rate=sampling_rate, **augmentation.params)
+ case "augmentation_pipeline":
+ return create_augmentation_pipeline(augmentation.params)
+ case "random_augmentation":
+ return nse.layers.preprocessing.RandomAugmentation1DPipeline(
+ layers=[
+ create_augmentation_layer(augmentation, sampling_rate=sampling_rate)
+ for augmentation in [NamedParams(**p) for p in augmentation.params["layers"]]
+ ],
+ augmentations_per_sample=augmentation.params.get("augmentations_per_sample", 3),
+ rate=augmentation.params.get("rate", 1.0),
+ batchwise=True,
+ )
+ case "random_background_noise":
+ nstdb = NstdbNoise(target_rate=sampling_rate)
+ noises = np.hstack(
+ (nstdb.get_noise(noise_type="bw"), nstdb.get_noise(noise_type="ma"), nstdb.get_noise(noise_type="em"))
+ )
+ noises = noises.astype(np.float32)
+ return nse.layers.preprocessing.RandomBackgroundNoises1D(noises=noises, **augmentation.params)
+ case "random_sine_wave":
+ return nse.layers.preprocessing.RandomSineWave(**augmentation.params, sample_rate=sampling_rate)
+ case "random_cutout":
+ return nse.layers.preprocessing.RandomCutout1D(**augmentation.params)
+ case "random_noise":
+ return nse.layers.preprocessing.RandomGaussianNoise1D(**augmentation.params)
+ case "random_noise_distortion":
+ return nse.layers.preprocessing.RandomNoiseDistortion1D(sample_rate=sampling_rate, **augmentation.params)
+ case "resizing":
+ return nse.layers.preprocessing.Resizing1D(**augmentation.params)
+ case "sine_wave":
+ return nse.layers.preprocessing.AddSineWave(**augmentation.params)
+ case "filter":
+ return nse.layers.preprocessing.CascadedBiquadFilter(sample_rate=sampling_rate, **augmentation.params)
+ case "layer_norm":
+ return nse.layers.preprocessing.LayerNormalization1D(**augmentation.params)
+ case _:
+ raise ValueError(f"Unknown augmentation '{augmentation.name}'")
+ # END MATCH
-def augment_pipeline(
- x: npt.NDArray,
- augmentations: list[AugmentationParams] | None = None,
- sample_rate: float = 1000,
-) -> tuple[npt.NDArray, npt.NDArray | None]:
- """Apply augmentation pipeline
+
+def create_augmentation_pipeline(
+ augmentations: list[NamedParams], sampling_rate: int
+) -> nse.layers.preprocessing.AugmentationPipeline:
+ """Create an augmentation pipeline from a list of augmentation configurations.
+
+ This is useful when running from a configuration file to hydrate the pipeline.
Args:
- x (npt.NDArray): Signal
- augmentations (list[AugmentationParams]): Augmentations to apply
- sample_rate: Sampling rate in Hz.
+ augmentations (list[NamedParams]): List of augmentation configurations
+ sampling_rate (int): Sampling rate of the data
Returns:
- npt.NDArray: Augmented signal
+ nse.layers.preprocessing.AugmentationPipeline: Augmentation pipeline
+
+ Example:
+
+ ```python
+ import heartkit as hk
+ x = keras.random.normal(shape=(256, 1), dtype="float32")
+
+ augmenter = hk.datasets.create_augmentation_pipeline([
+ hk.NamedParams(name="random_noise", params={"factor": 0.01}),
+ hk.NamedParams(name="random_cutout", params={"factor": 0.01, "cutouts": 2}),
+ ], sampling_rate=100)
+
+ y = augmenter(x)
"""
- x_sd = np.nanstd(x)
- augmentations = augmentations or []
- for augmentation in augmentations:
- args = augmentation.params
- match augmentation.name:
- case "baseline_wander":
- amplitude = args.get("amplitude", [0.05, 0.06])
- frequency = args.get("frequency", [0, 1])
- x = pk.signal.add_baseline_wander(
- x,
- amplitude=np.random.uniform(amplitude[0], amplitude[1]),
- frequency=np.random.uniform(frequency[0], frequency[1]),
- sample_rate=sample_rate,
- signal_sd=x_sd,
- )
- case "motion_noise":
- amplitude = args.get("amplitude", [0.5, 1.0])
- frequency = args.get("frequency", [0.4, 0.6])
- x = pk.signal.add_motion_noise(
- x,
- amplitude=np.random.uniform(amplitude[0], amplitude[1]),
- frequency=np.random.uniform(frequency[0], frequency[1]),
- sample_rate=sample_rate,
- signal_sd=x_sd,
- )
- case "burst_noise":
- amplitude = args.get("amplitude", [0.05, 0.5])
- frequency = args.get("frequency", [sample_rate / 4, sample_rate / 2])
- burst_number = args.get("burst_number", [0, 2])
- x = pk.signal.add_burst_noise(
- x,
- amplitude=np.random.uniform(amplitude[0], amplitude[1]),
- frequency=np.random.uniform(frequency[0], frequency[1]),
- num_bursts=np.random.randint(burst_number[0], burst_number[1]),
- sample_rate=sample_rate,
- signal_sd=x_sd,
- )
- case "powerline_noise":
- amplitude = args.get("amplitude", [0.005, 0.01])
- frequency = args.get("frequency", [50, 60])
- x = pk.signal.add_powerline_noise(
- x,
- amplitude=np.random.uniform(amplitude[0], amplitude[1]),
- frequency=np.random.uniform(frequency[0], frequency[1]),
- sample_rate=sample_rate,
- signal_sd=x_sd,
- )
- case "noise_sources":
- num_sources = args.get("num_sources", [1, 2])
- amplitude = args.get("amplitude", [0, 0.1])
- frequency = args.get("frequency", [0, sample_rate / 2])
- num_sources: int = np.random.randint(num_sources[0], num_sources[1])
- x = pk.signal.add_noise_sources(
- x,
- amplitudes=[np.random.uniform(amplitude[0], amplitude[1]) for _ in range(num_sources)],
- frequencies=[np.random.uniform(frequency[0], frequency[1]) for _ in range(num_sources)],
- noise_shapes=["laplace" for _ in range(num_sources)],
- sample_rate=sample_rate,
- signal_sd=x_sd,
- )
- case "lead_noise":
- scale = args.get("scale", [0.05, 0.25])
- x = pk.signal.add_lead_noise(
- x,
- scale=x_sd * np.random.uniform(scale[0], scale[1]),
- )
- case "cutout":
- feat_len = x.shape[0]
- prob = args.get("probability", [0, 0.25])[1]
- amp = args.get("amplitude", [0, 0])
- width = args.get("width", [0, 1])
- ctype = args.get("type", "cut")[0]
- if np.random.rand() < prob:
- dur = int(np.random.uniform(width[0], width[1]) * feat_len)
- start = np.random.randint(0, feat_len - dur)
- stop = start + dur
- scale = np.random.uniform(amp[0], amp[1]) * x_sd
- if ctype == 0: # Cut
- x[start:stop] = 0
- else: # noise
- x[start:stop] += np.random.normal(0, scale, size=x[start:stop].shape)
- # END IF
- # END IF
-
- case "nstdb":
- global _nstdb_glb # pylint: disable=global-statement
- if _nstdb_glb is None:
- _nstdb_glb = NstdbNoise(target_rate=sample_rate)
- _nstdb_glb.set_target_rate(sample_rate)
- noise_range = args.get("noise_level", [0.1, 0.1])
- noise_level = np.random.uniform(noise_range[0], noise_range[1])
- x = _nstdb_glb.apply_noise(x, noise_level)
-
- case _: # default
- pass
- # raise ValueError(f"Unknown augmentation '{augmentation.name}'")
- # END MATCH
- # END FOR
- return x
+ if not augmentations:
+ return keras.layers.Lambda(lambda x: x)
+ aug = nse.layers.preprocessing.AugmentationPipeline(
+ layers=[create_augmentation_layer(augmentation, sampling_rate=sampling_rate) for augmentation in augmentations]
+ )
+ return aug
diff --git a/heartkit/datasets/bidmc.py b/heartkit/datasets/bidmc.py
index 7a31444b..c5660509 100644
--- a/heartkit/datasets/bidmc.py
+++ b/heartkit/datasets/bidmc.py
@@ -1,7 +1,6 @@
import contextlib
import functools
import logging
-import os
import random
from typing import Generator
@@ -24,12 +23,10 @@ class BidmcDataset(HKDataset):
def __init__(
self,
- ds_path: os.PathLike,
leads: list[int] | None = None,
+ **kwargs,
) -> None:
- super().__init__(
- ds_path=ds_path,
- )
+ super().__init__(**kwargs)
self.leads = leads or list(BidmcLeadsMap.values())
@property
@@ -94,7 +91,7 @@ def patient_data(self, patient_id: int) -> Generator[h5py.Group, None, None]:
Returns:
Generator[h5py.Group, None, None]: Patient data
"""
- with h5py.File(self.ds_path / f"{self._pt_key(patient_id)}.h5", mode="r") as h5:
+ with h5py.File(self.path / f"{self._pt_key(patient_id)}.h5", mode="r") as h5:
yield h5
def signal_generator(
@@ -118,7 +115,7 @@ def signal_generator(
if target_rate is None:
target_rate = self.sampling_rate
- input_size = int(np.round((self.sampling_rate / target_rate) * frame_size))
+ input_size = int(np.ceil((self.sampling_rate / target_rate) * frame_size))
for pt in patient_generator:
with self.patient_data(pt) as h5:
data: h5py.Dataset = h5["data"][:]
@@ -130,6 +127,7 @@ def signal_generator(
x = np.nan_to_num(x).astype(np.float32)
if self.sampling_rate != target_rate:
x = pk.signal.resample_signal(x, self.sampling_rate, target_rate, axis=0)
+ x = x[:frame_size]
# END IF
yield x
# END FOR
diff --git a/heartkit/datasets/dataloader.py b/heartkit/datasets/dataloader.py
index 33263f91..df4122fb 100644
--- a/heartkit/datasets/dataloader.py
+++ b/heartkit/datasets/dataloader.py
@@ -1,192 +1,210 @@
import functools
import logging
-import math
-import os
-from typing import Callable, Generator
+from typing import Generator
+from collections.abc import Iterable
import numpy as np
import numpy.typing as npt
import tensorflow as tf
+import neuralspot_edge as nse
+
-from ..utils import load_pkl, save_pkl
from .dataset import HKDataset
-from .defines import PatientGenerator, Preprocessor
-from .utils import (
- create_dataset_from_data,
- create_interleaved_dataset_from_generator,
- uniform_id_generator,
-)
logger = logging.getLogger(__name__)
-def train_val_dataloader(
- ds: HKDataset,
- spec: tuple[tf.TensorSpec, tf.TensorSpec],
- data_generator: Callable[
- [PatientGenerator, int | list[int]], Generator[tuple[npt.NDArray, npt.NDArray], None, None]
- ],
- id_generator: PatientGenerator | None = None,
- train_patients: float | None = None,
- val_patients: float | None = None,
- val_pt_samples: int | None = None,
- val_file: os.PathLike | None = None,
- val_size: int | None = None,
- label_map: dict[int, int] | None = None,
- label_type: str | None = None,
- preprocess: Preprocessor | None = None,
- val_preprocess: Preprocessor | None = None,
- num_workers: int = 1,
-) -> tuple[tf.data.Dataset, tf.data.Dataset]:
- """Load training and validation TF datasets
-
- Args:
- train_patients (float | None, optional): # or proportion of train patients. Defaults to None.
- val_patients (float | None, optional): # or proportion of val patients. Defaults to None.
- train_pt_samples (int | list[int] | None, optional): # samples per patient for training. Defaults to None.
- val_pt_samples (int | list[int] | None, optional): # samples per patient for validation. Defaults to None.
- val_size (int | None, optional): Validation size. Defaults to 200*len(val_patient_ids).
- val_file (str | None, optional): Path to existing pickled validation file. Defaults to None.
- num_workers (int, optional): # of parallel workers. Defaults to 1.
-
- Returns:
- tuple[tf.data.Dataset, tf.data.Dataset]: Training and validation datasets
- """
-
- if id_generator is None:
- id_generator = functools.partial(uniform_id_generator, repeat=True)
-
- if val_patients is not None and val_patients >= 1:
- val_patients = int(val_patients)
-
- if val_preprocess is None:
- val_preprocess = preprocess
-
- val_pt_samples = val_pt_samples or 100
-
- # Get train patients
- train_patient_ids = ds.get_train_patient_ids()
- train_patient_ids = ds.filter_patients_for_labels(
- patient_ids=train_patient_ids,
- label_map=label_map,
- label_type=label_type,
- )
-
- # Use subset of training patients
- if train_patients is not None:
- num_pts = int(train_patients) if train_patients > 1 else int(train_patients * len(train_patient_ids))
- train_patient_ids = train_patient_ids[:num_pts]
- logger.debug(f"Using {len(train_patient_ids)} training patients")
- # END IF
-
- if ds.cachable and val_file and os.path.isfile(val_file):
- logger.debug(f"Loading validation data from file {val_file}")
- val = load_pkl(val_file)
- val_patient_ids = val["patient_ids"]
- train_patient_ids = np.setdiff1d(train_patient_ids, val_patient_ids)
- val_ds = create_dataset_from_data(val["x"], val["y"], spec)
-
- else:
- logger.debug("Splitting patients into train and validation")
- train_patient_ids, val_patient_ids = ds.split_train_test_patients(
+class HKDataloader:
+ ds: HKDataset
+ frame_size: int
+ sampling_rate: int
+ label_map: dict[int, int] | None
+ label_type: str | None
+
+ def __init__(
+ self,
+ ds: HKDataset,
+ frame_size: int = 1000,
+ sampling_rate: int = 100,
+ label_map: dict[int, int] | None = None,
+ label_type: str | None = None,
+ **kwargs,
+ ):
+ """HKDataloader is used to create a task specific dataloader for a dataset.
+ This class should be subclassed for specific task and dataset. If multiple datasets are needed for given task,
+ multiple dataloaders can be created. To simplify the process, the dataloaders can be placed in an ItemFactory.
+
+ Args:
+ ds (HKDataset): Dataset
+ frame_size (int, optional): Frame size. Defaults to 1000.
+ sampling_rate (int, optional): Sampling rate. Defaults to 100.
+ label_map (dict[int, int], optional): Label map. Defaults to None.
+ label_type (str, optional): Label type. Defaults to None.
+
+ Example:
+ ```python
+ from typing import Generator
+ import numpy as np
+ import numpy.typing as npt
+ import heartkit as hk
+
+ class MyDataloader(hk.HKDataloader):
+ def __init__(self, ds: hk.HKDataset, **kwargs):
+ super().__init__(ds=ds, **kwargs)
+
+ def patient_generator(
+ self,
+ patient_id: int,
+ samples_per_patient: list[int],
+ ) -> Generator[npt.NDArray, None, None]:
+
+ # Implement patient generator
+ with ds.patient_data(patient_id) as pt:
+ for _ in range(samples_per_patient):
+ data = pt["data"][:]
+ # Grab random frame and lead
+ lead = np.random.randint(0, data.shape[0])
+ start = np.random.randint(0, data.shape[1] - self.frame_size)
+ frame = data[lead, start : start + self.frame_size]
+ yield frame
+
+ def data_generator(
+ self,
+ patient_ids: list[int],
+ samples_per_patient: int | list[int],
+ shuffle: bool = False,
+ ) -> Generator[npt.NDArray, None, None]:
+ for pt_id in nse.utils.uniform_id_generator(patient_ids, shuffle=shuffle):
+ # Implement data generator
+ yield data
+ # END FOR
+
+ """
+ self.ds = ds
+ self.frame_size = frame_size
+ self.sampling_rate = sampling_rate
+ self.label_map = label_map
+ self.label_type = label_type
+
+ def split_train_val_patients(
+ self,
+ train_patients: list[int] | float | None = None,
+ val_patients: list[int] | float | None = None,
+ ) -> tuple[list[int], list[int]]:
+ """Split patients into training and validation sets. Unless train_patients or
+ val_patients are provided, the default is to call the dataset's split_train_test_patients
+
+ Args:
+ train_patients (list[int] | float | None, optional): Training patients. Defaults to None.
+ val_patients (list[int] | float | None, optional): Validation patients. Defaults to None.
+
+ Returns:
+ tuple[list[int], list[int]]: Training and validation patient ids
+ """
+ # Get train patients
+ train_patient_ids = self.ds.get_train_patient_ids()
+ train_patient_ids = self.ds.filter_patients_for_labels(
patient_ids=train_patient_ids,
- test_size=val_patients,
- label_map=label_map,
- label_type=label_type,
- )
- if val_size is None:
- num_samples = np.mean(val_pt_samples) if isinstance(val_pt_samples, list) else val_pt_samples
- val_size = math.ceil(num_samples * len(val_patient_ids))
-
- logger.debug(f"Collecting {val_size} validation samples")
-
- val_ds = create_interleaved_dataset_from_generator(
- data_generator=data_generator,
- id_generator=id_generator,
- ids=val_patient_ids,
- spec=spec,
- preprocess=val_preprocess,
- num_workers=num_workers,
+ label_map=self.label_map,
+ label_type=self.label_type,
)
- val_x, val_y = next(val_ds.batch(val_size).as_numpy_iterator())
- val_ds = create_dataset_from_data(val_x, val_y, spec)
+ # Use subset of training patients
+ if isinstance(train_patients, Iterable):
+ train_patient_ids = train_patients
- # Cache validation set
- if ds.cachable and val_file:
- logger.debug(f"Caching the validation set in {val_file}")
- os.makedirs(os.path.dirname(val_file), exist_ok=True)
- save_pkl(val_file, x=val_x, y=val_y, patient_ids=val_patient_ids)
+ if train_patients is not None:
+ num_pts = int(train_patients) if train_patients > 1 else int(train_patients * len(train_patient_ids))
+ train_patient_ids = train_patient_ids[:num_pts]
+ logger.debug(f"Using {len(train_patient_ids)} training patients")
# END IF
- # END IF
-
- logger.debug("Building train dataset")
-
- train_ds = create_interleaved_dataset_from_generator(
- data_generator=data_generator,
- id_generator=id_generator,
- ids=train_patient_ids,
- spec=spec,
- preprocess=preprocess,
- num_workers=num_workers,
- )
-
- return train_ds, val_ds
-
-
-def test_dataloader(
- ds: HKDataset,
- spec: tuple[tf.TensorSpec, tf.TensorSpec],
- data_generator: Callable[
- [PatientGenerator, int | list[int]], Generator[tuple[npt.NDArray, npt.NDArray], None, None]
- ],
- id_generator: PatientGenerator | None = None,
- test_patients: float | None = None,
- test_file: os.PathLike | None = None,
- label_map: dict[int, int] | None = None,
- label_type: str | None = None,
- preprocess: Preprocessor | None = None,
- num_workers: int = 1,
-) -> tf.data.Dataset:
- """Load testing datasets
-
- Args:
- test_patients (float | None, optional): # or proportion of test patients. Defaults to None.
- test_pt_samples (int | None, optional): # samples per patient for testing. Defaults to None.
- test_file (str | None, optional): Path to existing pickled test file. Defaults to None.
- repeat (bool, optional): Restart generator when dataset is exhausted. Defaults to True.
- num_workers (int, optional): # of parallel workers. Defaults to 1.
-
- Returns:
- tf.data.Dataset: Test dataset
- """
-
- # Get test patients
- test_patient_ids = ds.get_test_patient_ids()
- test_patient_ids = ds.filter_patients_for_labels(
- patient_ids=test_patient_ids,
- label_map=label_map,
- label_type=label_type,
- )
-
- if test_patients is not None:
- num_pts = int(test_patients) if test_patients > 1 else int(test_patients * len(test_patient_ids))
- test_patient_ids = test_patient_ids[:num_pts]
-
- # Use existing validation data
- if ds.cachable and test_file and os.path.isfile(test_file):
- logger.debug(f"Loading test data from file {test_file}")
- test = load_pkl(test_file)
- test_ds = create_dataset_from_data(test["x"], test["y"], spec)
- test_patient_ids = test["patient_ids"]
- else:
- test_ds = create_interleaved_dataset_from_generator(
- data_generator=data_generator,
- id_generator=id_generator,
- ids=test_patient_ids,
- spec=spec,
- preprocess=preprocess,
- num_workers=num_workers,
+
+ # Use subset of validation patients
+ if isinstance(val_patients, Iterable):
+ val_patient_ids = val_patients
+ train_patient_ids = np.setdiff1d(train_patient_ids, val_patient_ids).tolist()
+ return train_patient_ids, val_patient_ids
+
+ if val_patients is not None and val_patients >= 1:
+ val_patients = int(val_patients)
+
+ train_patient_ids, val_patient_ids = self.ds.split_train_test_patients(
+ patient_ids=train_patient_ids,
+ test_size=val_patients,
+ label_map=self.label_map,
+ label_type=self.label_type,
+ )
+
+ return train_patient_ids, val_patient_ids
+
+ def test_patient_ids(
+ self,
+ test_patients: float | None = None,
+ ) -> list[int]:
+ """Get test patient ids
+
+ Args:
+ test_patients (float | None, optional): Test patients. Defaults to None.
+
+ Returns:
+ list[int]: Test patient ids
+ """
+ test_patient_ids = self.ds.get_test_patient_ids()
+ test_patient_ids = self.ds.filter_patients_for_labels(
+ patient_ids=test_patient_ids,
+ label_map=self.label_map,
+ label_type=self.label_type,
)
- return test_ds
+ if test_patients is not None:
+ num_pts = int(test_patients) if test_patients > 1 else int(test_patients * len(test_patient_ids))
+ test_patient_ids = test_patient_ids[:num_pts]
+
+ return test_patient_ids
+
+ def data_generator(
+ self,
+ patient_ids: list[int],
+ samples_per_patient: int | list[int],
+ shuffle: bool = False,
+ ) -> Generator[tuple[npt.NDArray, ...], None, None]:
+ """Generate data for given patient ids
+
+ !!! note
+ This method should be implemented in the subclass
+
+ Args:
+ patient_ids (list[int]): Patient IDs
+ samples_per_patient (int | list[int]): Samples per patient
+ shuffle (bool, optional): Shuffle data. Defaults to False.
+ """
+ raise NotImplementedError()
+
+ def create_dataloader(
+ self, patient_ids: list[int], samples_per_patient: int | list[int], shuffle: bool = False
+ ) -> tf.data.Dataset:
+ """Create tf.data.Dataset from internal data generator
+
+ Args:
+ patient_ids (list[int]): Patient IDs
+ samples_per_patient (int | list[int]): Samples per patient
+ shuffle (bool, optional): Shuffle data. Defaults to False.
+
+ Returns:
+ tf.data.Dataset: Dataset
+ """
+ data_gen = functools.partial(
+ self.data_generator,
+ patient_ids=patient_ids,
+ samples_per_patient=samples_per_patient,
+ shuffle=shuffle,
+ )
+
+ # Compute output signature from generator
+ sig = nse.utils.get_output_signature_from_gen(data_gen)
+
+ ds = tf.data.Dataset.from_generator(
+ data_gen,
+ output_signature=sig,
+ )
+ return ds
diff --git a/heartkit/datasets/dataset.py b/heartkit/datasets/dataset.py
index 1dfd3faf..70bb3fcf 100644
--- a/heartkit/datasets/dataset.py
+++ b/heartkit/datasets/dataset.py
@@ -5,33 +5,95 @@
from pathlib import Path
from typing import Generator
-import h5py
import numpy.typing as npt
-import sklearn
+import sklearn.model_selection
-from .defines import PatientGenerator
+from .defines import PatientGenerator, PatientData
logger = logging.getLogger(__name__)
class HKDataset(abc.ABC):
- """HeartKit dataset base class"""
+ path: Path
+ _cacheable: bool
+ _cached_data: dict[str, npt.NDArray]
- ds_path: Path
+ def __init__(self, path: os.PathLike, cacheable: bool = True) -> None:
+ """HKDataset serves as a base class to download and provide unified access to datasets.
- def __init__(self, ds_path: os.PathLike) -> None:
- """HeartKit dataset base class"""
- self.ds_path = Path(ds_path)
+ Args:
+ path (os.PathLike): Path to dataset
+ cacheable (bool, optional): If dataset supports file caching. Defaults
+
+ Example:
+ ```python
+ import numpy as np
+ import heartkit as hk
+
+ class MyDataset(hk.HKDataset):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ @property
+ def name(self) -> str:
+ return 'my-dataset'
+
+ @property
+ def sampling_rate(self) -> int:
+ return 100
+
+ def get_train_patient_ids(self) -> npt.NDArray:
+ return np.arange(80)
+
+ def get_test_patient_ids(self) -> npt.NDArray:
+ return np.arange(80, 100)
+
+ @contextlib.contextmanager
+ def patient_data(self, patient_id: int) -> Generator[PatientData, None, None]:
+ data = np.random.randn(1000)
+ segs = np.random.randint(0, 1000, (10, 2))
+ yield {"data": data, "segmentations": segs}
+
+ def signal_generator(
+ self,
+ patient_generator: PatientGenerator,
+ frame_size: int,
+ samples_per_patient: int = 1,
+ target_rate: int | None = None,
+ ) -> Generator[npt.NDArray, None, None]:
+ for patient in patient_generator:
+ for _ in range(samples_per_patient):
+ with self.patient_data(patient) as pt:
+ yield pt["data"]
+
+ def download(self, num_workers: int | None = None, force: bool = False):
+ pass
+
+ # Register dataset
+ hk.DatasetFactory.register("my-dataset", MyDataset)
+ ```
+ """
+ self.path = Path(path)
+ self._cacheable = cacheable
+ self._cached_data = {}
@property
def name(self) -> str:
"""Dataset name"""
- return self.ds_path.stem
+ return self.path.stem
@property
- def cachable(self) -> bool:
- """If dataset supports file caching."""
- return True
+ def cacheable(self) -> bool:
+ """If dataset supports in-memory caching.
+
+ On smaller datasets, it is recommended to cache the entire dataset in memory.
+ """
+ return self._cacheable
+
+ @cacheable.setter
+ def cacheable(self, value: bool):
+ """Set if in-memory caching is enabled"""
+ self._cacheable = value
@property
def sampling_rate(self) -> int:
@@ -49,7 +111,7 @@ def std(self) -> float:
return 1
def get_train_patient_ids(self) -> npt.NDArray:
- """Get training patient IDs
+ """Get dataset's defined training patient IDs
Returns:
npt.NDArray: patient IDs
@@ -57,7 +119,7 @@ def get_train_patient_ids(self) -> npt.NDArray:
raise NotImplementedError()
def get_test_patient_ids(self) -> npt.NDArray:
- """Get patient IDs reserved for testing only
+ """Get dataset's patient IDs reserved for testing only
Returns:
npt.NDArray: patient IDs
@@ -65,14 +127,14 @@ def get_test_patient_ids(self) -> npt.NDArray:
raise NotImplementedError()
@contextlib.contextmanager
- def patient_data(self, patient_id: int) -> Generator[h5py.Group, None, None]:
+ def patient_data(self, patient_id: int) -> Generator[PatientData, None, None]:
"""Get patient data
Args:
patient_id (int): Patient ID
Returns:
- Generator[h5py.Group, None, None]: Patient data
+ Generator[PatientData, None, None]: Patient data
"""
raise NotImplementedError()
@@ -86,12 +148,13 @@ def signal_generator(
"""Generate random frames.
Args:
- patient_generator (PatientGenerator): Generator that yields a tuple of patient id and patient data.
- Patient data may contain only signals, since labels are not used.
- samples_per_patient (int): Samples per patient.
+ patient_generator (PatientGenerator): Generator that yields patient data.
+ frame_size (int): Frame size
+ samples_per_patient (int, optional): Samples per patient. Defaults to 1.
+ target_rate (int | None, optional): Target rate. Defaults to None.
Returns:
- Generator[npt.NDArray, None, None]: Generator of input data of shape (frame_size, 1)
+ Generator[npt.NDArray, None, None]: Generator sample of data
"""
raise NotImplementedError()
diff --git a/heartkit/datasets/defines.py b/heartkit/datasets/defines.py
index ce4e5cce..28334f8c 100644
--- a/heartkit/datasets/defines.py
+++ b/heartkit/datasets/defines.py
@@ -1,7 +1,8 @@
-from typing import Callable, Generator
+from typing import Generator, TypeAlias
import numpy.typing as npt
-
-Preprocessor = Callable[[tuple[npt.NDArray, npt.NDArray]], tuple[npt.NDArray, npt.NDArray]]
+import h5py
PatientGenerator = Generator[int, None, None]
+
+PatientData: TypeAlias = dict[str, npt.NDArray] | h5py.Group
diff --git a/heartkit/datasets/download.py b/heartkit/datasets/download.py
index 41671256..3659ddd3 100644
--- a/heartkit/datasets/download.py
+++ b/heartkit/datasets/download.py
@@ -1,19 +1,37 @@
import logging
import os
+import neuralspot_edge as nse
from ..defines import HKDownloadParams
-from ..utils import setup_logger
-from . import DatasetFactory
+from . import HKDataset
+from .factory import DatasetFactory
-logger = setup_logger(__name__)
+
+logger = nse.utils.setup_logger(__name__)
def download_datasets(params: HKDownloadParams):
"""Download specified datasets.
Args:
- params (HeartDownloadParams): Download parameters
-
+ params (HKDownloadParams): Download parameters
+
+ Example:
+ ```python
+ import heartkit as hk
+
+ # Download datasets
+ params = hk.HKDownloadParams(
+ datasets=[
+ hk.NamedParams(name="ptbxl", params={
+ "path": "./datasets/ptbxl",
+ }),
+ ],
+ data_parallelism=4,
+ force=False,
+ )
+ hk.datasets.download_datasets(params)
+ ```
"""
os.makedirs(params.job_dir, exist_ok=True)
logger.debug(f"Creating working directory in {params.job_dir}")
@@ -24,9 +42,8 @@ def download_datasets(params: HKDownloadParams):
for ds in params.datasets:
if DatasetFactory.has(ds.name):
- os.makedirs(ds.path, exist_ok=True)
Dataset = DatasetFactory.get(ds.name)
- ds = Dataset(ds_path=ds.path, **ds.params)
+ ds: HKDataset = Dataset(**ds.params)
ds.download(
num_workers=params.data_parallelism,
force=params.force,
diff --git a/heartkit/datasets/synthetic.py b/heartkit/datasets/ecg_synthetic.py
similarity index 67%
rename from heartkit/datasets/synthetic.py
rename to heartkit/datasets/ecg_synthetic.py
index 143acfdb..defc3db3 100644
--- a/heartkit/datasets/synthetic.py
+++ b/heartkit/datasets/ecg_synthetic.py
@@ -1,9 +1,7 @@
import contextlib
-import io
-import logging
-import os
import random
-import uuid
+import tempfile
+from pathlib import Path
from typing import Generator
import h5py
@@ -11,16 +9,19 @@
import numpy.typing as npt
import physiokit as pk
from pydantic import BaseModel, Field
+import neuralspot_edge as nse
+from tqdm.contrib.concurrent import process_map
+
from .dataset import HKDataset
-from .defines import PatientGenerator
+from .defines import PatientGenerator, PatientData
from .nstdb import NstdbNoise
-logger = logging.getLogger(__name__)
+logger = nse.utils.setup_logger(__name__)
-class SyntheticParams(BaseModel, extra="allow"):
- """Synthetic parameters"""
+class EcgSyntheticParams(BaseModel, extra="allow"):
+ """ECG Synthetic ECG generator parameters"""
presets: list[pk.ecg.EcgPreset] = Field(
default_factory=lambda: [
@@ -48,36 +49,53 @@ class SyntheticParams(BaseModel, extra="allow"):
voltage_factor: tuple[float, float] = Field((800, 1000), description="Voltage factor range")
-class SyntheticDataset(HKDataset):
- """Synthetic dataset"""
-
+class EcgSyntheticDataset(HKDataset):
def __init__(
self,
- ds_path: os.PathLike,
num_pts: int = 250,
leads: list[int] | None = None,
params: dict | None = None,
+ path: str = Path(tempfile.gettempdir()) / "ecg-synthetic",
+ **kwargs,
) -> None:
- super().__init__(
- ds_path=ds_path,
+ """ECG synthetic dataset creates 12-lead ECG signals using PhysioKit.
+
+ Args:
+ num_pts (int, optional): Number of patients. Defaults to 250.
+ leads (list[int] | None, optional): Leads to use. Defaults to None.
+ params (dict | None, optional): ECG synthetic parameters for EcgSyntheticParams. Defaults to None.
+ path (str, optional): Path to store dataset. Defaults to Path(tempfile.gettempdir()) / "ecg-synthetic".
+
+ Example:
+ ```python
+ import heartkit as hk
+
+ ds = hk.datasets.EcgSyntheticDataset(
+ num_pts=100,
+ params=dict(
+ sample_rate=1000, # Hz
+ duration=10, # seconds
+ heart_rate=(40, 120),
+ )
)
+
+ with ds.patient_data(patient_id=ds.patient_ids[0]) as pt:
+ ecg = pt["data"][:]
+ segs = pt["segmentations"][:]
+ fids = pt["fiducials"][:]
+ # END WITH
+ ```
+ """
+ super().__init__(path=path, **kwargs)
self._noise_gen = None
self._num_pts = num_pts
self.leads = leads or list(range(12))
- self.params = SyntheticParams(**params or {})
- self._unique_id = str(uuid.uuid4())
- self._cache: dict[str, io.BytesIO] = {}
- os.makedirs(self.ds_path, exist_ok=True)
+ self.params = EcgSyntheticParams(**params or {})
@property
def name(self) -> str:
"""Dataset name"""
- return "synthetic"
-
- @property
- def cachable(self) -> bool:
- """If dataset supports file caching."""
- return True
+ return "ecg-synthetic"
@property
def sampling_rate(self) -> int:
@@ -125,37 +143,48 @@ def pt_key(self, patient_id: int):
"""Get patient key"""
return f"{patient_id:05d}"
+ def load_patient_data(self, patient_id: int):
+ ecg, segs, fids = self._synthesize_signal(
+ frame_size=int(self.params.duration * self.sampling_rate), target_rate=self.sampling_rate
+ )
+ pt_data = {
+ "data": ecg,
+ "segmentations": segs,
+ "fiducials": fids,
+ }
+ return pt_data
+
+ def build_cache(self):
+ """Build in-memory cache to speed up data access"""
+ logger.info(f"Creating synthetic dataset cache with {self._num_pts} patients")
+ pts_data = process_map(self.load_patient_data, self.patient_ids)
+ self._cached_data = {self.pt_key(i): pt_data for i, pt_data in enumerate(pts_data)}
+
@contextlib.contextmanager
- def patient_data(self, patient_id: int) -> Generator[h5py.Group, None, None]:
- """Get patient data
+ def patient_data(self, patient_id: int) -> Generator[PatientData, None, None]:
+ """Get access to patient data
+
+ Patient data contains following fields:
+ - data: ECG signal of shape (12, N)
+ - segmentations: Segmentation of ECG signal
+ - fiducials: Fiducials of ECG signal
Args:
patient_id (int): Patient ID
Returns:
- Generator[h5py.Group, None, None]: Patient data
+ Generator[PatientData, None, None]: Patient data
"""
-
pt_key = self.pt_key(patient_id)
- if pt_key not in self._cache:
- ecg, segs, fids = self._synthesize_signal(
- frame_size=int(self.params.duration * self.sampling_rate), target_rate=self.sampling_rate
- )
- fp = io.BytesIO()
- with h5py.File(fp, mode="w") as h5:
- h5.create_dataset("data", data=ecg)
- h5.create_dataset("segmentations", data=segs)
- h5.create_dataset("fiducials", data=fids)
- h5.attrs["unique_id"] = self._unique_id
- # END WITH
- fp.seek(0)
- self._cache[pt_key] = fp
+ if self.cacheable:
+ if pt_key not in self._cached_data:
+ self.build_cache()
+ yield self._cached_data[pt_key]
+ else:
+ pt_data = self.load_patient_data(patient_id)
+ yield pt_data
# END IF
- with h5py.File(self._cache[pt_key], mode="r") as h5:
- yield h5
- # END WITH
-
def signal_generator(
self,
patient_generator: PatientGenerator,
@@ -166,8 +195,10 @@ def signal_generator(
"""Generate frames using patient generator.
Args:
- patient_generator (PatientGenerator): Generator that yields a tuple of patient id and patient data.
- samples_per_patient (int): Samples per patient.
+ patient_generator (PatientGenerator): Generator that yields patient data.
+ frame_size (int): Frame size
+ samples_per_patient (int, optional): Samples per patient. Defaults to 1.
+ target_rate (int | None, optional): Target rate. Defaults to None.
Returns:
SampleGenerator: Generator of input data of shape (frame_size, 1)
@@ -175,7 +206,7 @@ def signal_generator(
if target_rate is None:
target_rate = self.sampling_rate
- input_size = int(np.round((self.sampling_rate / target_rate) * frame_size))
+ input_size = int(np.ceil((self.sampling_rate / target_rate) * frame_size))
for pt in patient_generator:
with self.patient_data(pt) as h5:
@@ -189,6 +220,7 @@ def signal_generator(
x = self.add_noise(x)
if self.sampling_rate != target_rate:
x = pk.signal.resample_signal(x, self.sampling_rate, target_rate, axis=0)
+ x = x[:frame_size]
# END IF
yield x
# END FOR
@@ -228,7 +260,7 @@ def _synthesize_signal(
frame_size: int,
target_rate: float | None = None,
) -> tuple[npt.NDArray, npt.NDArray, npt.NDArray]:
- """Generate synthetic signal of given length
+ """Private method to generate synthetic signal of given length
Args:
frame_size (int): Frame size
diff --git a/heartkit/datasets/factory.py b/heartkit/datasets/factory.py
new file mode 100644
index 00000000..146d116c
--- /dev/null
+++ b/heartkit/datasets/factory.py
@@ -0,0 +1,10 @@
+"""DatasetFactory is used to store and retrieve datasets that inherit from HKDataset.
+key (str): Dataset name slug (e.g. "ptbxl")
+value (HKDataset): Dataset class
+"""
+
+import neuralspot_edge as nse
+
+from .dataset import HKDataset
+
+DatasetFactory = nse.utils.create_factory(factory="HKDatasetFactory", type=HKDataset)
diff --git a/heartkit/datasets/icentia11k.py b/heartkit/datasets/icentia11k.py
index 4f29a0ba..2567bc4e 100644
--- a/heartkit/datasets/icentia11k.py
+++ b/heartkit/datasets/icentia11k.py
@@ -1,12 +1,10 @@
import contextlib
import functools
-import logging
import os
import random
import tempfile
import zipfile
from enum import IntEnum
-from multiprocessing import Pool
from typing import Generator
import h5py
@@ -15,14 +13,13 @@
import physiokit as pk
import sklearn.model_selection
import sklearn.preprocessing
-from tqdm import tqdm
+from tqdm.contrib.concurrent import process_map
+import neuralspot_edge as nse
-from ..utils import download_file
from .dataset import HKDataset
from .defines import PatientGenerator
-from .utils import download_s3_objects
-logger = logging.getLogger(__name__)
+logger = nse.utils.setup_logger(__name__)
class IcentiaRhythm(IntEnum):
@@ -51,14 +48,17 @@ class IcentiaBeat(IntEnum):
class IcentiaDataset(HKDataset):
- """Icentia dataset"""
-
def __init__(
self,
- ds_path: os.PathLike,
leads: list[int] | None = None,
+ **kwargs,
) -> None:
- super().__init__(ds_path=ds_path)
+ """Icentia11kDataset consists of ECG recordings from 11,000 patients and 2 billion labelled beats.
+
+ Args:
+ leads (list[int] | None, optional): List of leads to include. Defaults to None.
+ """
+ super().__init__(**kwargs)
self.leads = leads or list(IcentiaLeadsMap.values())
@property
@@ -107,10 +107,11 @@ def get_test_patient_ids(self) -> npt.NDArray:
return self.patient_ids[10_000:]
def _pt_key(self, patient_id: int):
+ """Get patient key for HDF5 file"""
return f"p{patient_id:05d}"
def label_key(self, label_type: str = "rhythm") -> str:
- """Get label key
+ """Get local label key for HDF5 file
Args:
label_type (str, optional): Label type. Defaults to "rhythm".
@@ -128,13 +129,19 @@ def label_key(self, label_type: str = "rhythm") -> str:
def patient_data(self, patient_id: int) -> Generator[h5py.Group, None, None]:
"""Get patient data
+ Patient data is stored in HDF5 format with the following structure:
+ - {segment_id}/data: ECG data (1 x N)
+ - {segment_id}/rlabels: Rhythm labels (N x 2)
+ - {segment_id}/blabels: Beat labels (N x 2)
+ segment_id is sequential number for each segment in the patient data.
+
Args:
patient_id (int): Patient ID
Returns:
Generator[h5py.Group, None, None]: Patient data
"""
- with h5py.File(self.ds_path / f"{self._pt_key(patient_id)}.h5", mode="r") as h5:
+ with h5py.File(self.path / f"{self._pt_key(patient_id)}.h5", mode="r") as h5:
yield h5[self._pt_key(patient_id)]
def signal_generator(
@@ -147,7 +154,7 @@ def signal_generator(
"""Generate random frames.
Args:
- patient_generator (PatientGenerator): Patient generator
+ patient_generator (PatientGenerator): Generator that yields patient data.
frame_size (int): Frame size
samples_per_patient (int, optional): Samples per patient. Defaults to 1.
target_rate (int | None, optional): Target rate. Defaults to None.
@@ -158,7 +165,7 @@ def signal_generator(
if target_rate is None:
target_rate = self.sampling_rate
- input_size = int(np.round((self.sampling_rate / target_rate) * frame_size))
+ input_size = int(np.ceil((self.sampling_rate / target_rate) * frame_size))
for pt in patient_generator:
with self.patient_data(pt) as segments:
for _ in range(samples_per_patient):
@@ -170,6 +177,7 @@ def signal_generator(
x = np.nan_to_num(x).astype(np.float32)
if self.sampling_rate != target_rate:
x = pk.signal.resample_signal(x, self.sampling_rate, target_rate, axis=0)
+ x = x[:frame_size]
# END IF
yield x
# END FOR
@@ -185,10 +193,10 @@ def download(self, num_workers: int | None = None, force: bool = False):
num_workers (int | None, optional): # parallel workers. Defaults to None.
force (bool, optional): Force redownload. Defaults to False.
"""
- download_s3_objects(
+ nse.utils.download_s3_objects(
bucket="ambiq-ai-datasets",
- prefix=self.ds_path.stem,
- dst=self.ds_path.parent,
+ prefix=self.path.stem,
+ dst=self.path.parent,
checksum="size",
progress=True,
num_workers=num_workers,
@@ -284,8 +292,7 @@ def get_patients_labels(
"""
ids = patient_ids.tolist()
func = functools.partial(self.get_patient_labels, label_map=label_map, label_type=label_type)
- with Pool() as pool:
- pts_labels = list(pool.imap(func, ids))
+ pts_labels = process_map(func, ids)
return pts_labels
def get_patient_labels(self, patient_id: int, label_map: dict[int, int], label_type: str = "rhythm") -> list[int]:
@@ -328,14 +335,14 @@ def download_raw_dataset(self, num_workers: int | None = None, force: bool = Fal
"https://physionet.org/static/published-projects/icentia11k-continuous-ecg/"
"icentia11k-single-lead-continuous-raw-electrocardiogram-dataset-1.0.zip"
)
- ds_zip_path = self.ds_path / "icentia11k.zip"
- os.makedirs(self.ds_path, exist_ok=True)
+ ds_zip_path = self.path / "icentia11k.zip"
+ os.makedirs(self.path, exist_ok=True)
if os.path.exists(ds_zip_path) and not force:
logger.warning(
f"Zip file already exists. Please delete or set `force` flag to redownload. PATH={ds_zip_path}"
)
else:
- download_file(ds_url, ds_zip_path, progress=True)
+ nse.utils.download_file(ds_url, ds_zip_path, progress=True)
# 2. Extract and convert patient ECG data to H5 files
logger.debug("Generating icentia11k patient data")
@@ -376,7 +383,7 @@ def _convert_dataset_pt_zip_to_hdf5(self, patient: int, zip_path: os.PathLike, f
logger.debug(f"Processing patient {patient}")
pt_id = self._pt_key(patient)
- pt_path = self.ds_path / f"{pt_id}.h5"
+ pt_path = self.path / f"{pt_id}.h5"
if not force and os.path.exists(pt_path):
logger.debug(f"Skipping patient {pt_id}")
return
@@ -450,5 +457,4 @@ def _convert_dataset_zip_to_hdf5(
if not patient_ids:
patient_ids = self.patient_ids
f = functools.partial(self._convert_dataset_pt_zip_to_hdf5, zip_path=zip_path, force=force)
- with Pool(processes=num_workers) as pool:
- _ = list(tqdm(pool.imap(f, patient_ids), total=len(patient_ids)))
+ _ = process_map(f, patient_ids)
diff --git a/heartkit/datasets/icentia_mini.py b/heartkit/datasets/icentia_mini.py
new file mode 100644
index 00000000..63019827
--- /dev/null
+++ b/heartkit/datasets/icentia_mini.py
@@ -0,0 +1,325 @@
+import contextlib
+import functools
+import os
+import random
+import zipfile
+from enum import IntEnum
+from typing import Generator
+
+import h5py
+import numpy as np
+import numpy.typing as npt
+import physiokit as pk
+import sklearn.model_selection
+import sklearn.preprocessing
+from tqdm.contrib.concurrent import process_map
+
+import neuralspot_edge as nse
+
+from .dataset import HKDataset
+from .defines import PatientGenerator, PatientData
+
+logger = nse.utils.setup_logger(__name__)
+
+
+class IcentiaMiniRhythm(IntEnum):
+ """Icentia rhythm labels"""
+
+ normal = 1
+ afib = 2
+ aflut = 3
+ end = 4
+
+
+class IcentiaMiniBeat(IntEnum):
+ """Incentia mini beat labels"""
+
+ normal = 1
+ pac = 2
+ aberrated = 3
+ pvc = 4
+
+
+IcentiaMiniLeadsMap = {
+ "i": 0, # Modified lead I
+}
+
+
+class IcentiaMiniDataset(HKDataset):
+ """Icentia-mini dataset"""
+
+ def __init__(
+ self,
+ leads: list[int] | None = None,
+ **kwargs,
+ ) -> None:
+ super().__init__(**kwargs)
+ self.leads = leads or list(IcentiaMiniLeadsMap.values())
+
+ @property
+ def name(self) -> str:
+ """Dataset name"""
+ return "icentia_mini"
+
+ @property
+ def sampling_rate(self) -> int:
+ """Sampling rate in Hz"""
+ return 250
+
+ @property
+ def mean(self) -> float:
+ """Dataset mean"""
+ return 0.0018
+
+ @property
+ def std(self) -> float:
+ """Dataset st dev"""
+ return 1.3711
+
+ @property
+ def patient_ids(self) -> npt.NDArray:
+ """Get dataset patient IDs
+
+ Returns:
+ npt.NDArray: patient IDs
+ """
+ return np.arange(11_000)
+
+ def get_train_patient_ids(self) -> npt.NDArray:
+ """Get training patient IDs
+
+ Returns:
+ npt.NDArray: patient IDs
+ """
+ return self.patient_ids[:10_000]
+
+ def get_test_patient_ids(self) -> npt.NDArray:
+ """Get patient IDs reserved for testing only
+
+ Returns:
+ npt.NDArray: patient IDs
+ """
+ return self.patient_ids[10_000:]
+
+ def _pt_key(self, patient_id: int):
+ return f"p{patient_id:05d}"
+
+ def label_key(self, label_type: str = "rhythm") -> str:
+ """Get label key
+
+ Args:
+ label_type (str, optional): Label type. Defaults to "rhythm".
+
+ Returns:
+ str: Label key
+ """
+ if label_type == "rhythm":
+ return "rlabels"
+ if label_type == "beat":
+ return "blabels"
+ raise ValueError(f"Invalid label type: {label_type}")
+
+ @contextlib.contextmanager
+ def patient_data(self, patient_id: int) -> Generator[PatientData, None, None]:
+ """Get patient data
+
+ Args:
+ patient_id (int): Patient ID
+
+ Returns:
+ Generator[h5py.Group, None, None]: Patient data
+ """
+ h5_path = self.path / "icentia_mini.h5"
+ pt_key = self._pt_key(patient_id)
+ if self.cacheable:
+ if patient_id not in self._cached_data:
+ pt_data = {}
+ with h5py.File(h5_path, mode="r") as h5:
+ pt = h5[pt_key]
+ pt_data["data"] = pt["data"][:]
+ pt_data["rlabels"] = pt["rlabels"][:]
+ pt_data["blabels"] = pt["blabels"][:]
+ self._cached_data[patient_id] = pt_data
+ # END IF
+ yield self._cached_data[patient_id]
+ else:
+ with h5py.File(h5_path, mode="r") as h5:
+ pt = h5[pt_key]
+ yield h5
+ # END WITH
+ # END IF
+
+ def signal_generator(
+ self,
+ patient_generator: PatientGenerator,
+ frame_size: int,
+ samples_per_patient: int = 1,
+ target_rate: int | None = None,
+ ) -> Generator[npt.NDArray, None, None]:
+ """Generate random frames.
+
+ Args:
+ patient_generator (PatientGenerator): Generator that yields patient data.
+ frame_size (int): Frame size
+ samples_per_patient (int, optional): Samples per patient. Defaults to 1.
+ target_rate (int | None, optional): Target rate. Defaults to None.
+
+ Returns:
+ SampleGenerator: Generator of input data of shape (frame_size, 1)
+ """
+ if target_rate is None:
+ target_rate = self.sampling_rate
+
+ input_size = int(np.ceil((self.sampling_rate / target_rate) * frame_size))
+ for pt in patient_generator:
+ with self.patient_data(pt) as segments:
+ for _ in range(samples_per_patient):
+ segment = segments[np.random.choice(list(segments.keys()))]
+ segment_size = segment["data"].shape[0]
+ frame_start = np.random.randint(segment_size - input_size)
+ frame_end = frame_start + input_size
+ x = segment["data"][frame_start:frame_end].squeeze()
+ x = np.nan_to_num(x).astype(np.float32)
+ if self.sampling_rate != target_rate:
+ x = pk.signal.resample_signal(x, self.sampling_rate, target_rate, axis=0)
+ x = x[:frame_size]
+ # END IF
+ yield x
+ # END FOR
+ # END WITH
+ # END FOR
+
+ def download(self, num_workers: int | None = None, force: bool = False):
+ """Download dataset
+
+ This will download preprocessed HDF5 files from S3.
+
+ Args:
+ num_workers (int | None, optional): # parallel workers. Defaults to None.
+ force (bool, optional): Force redownload. Defaults to False.
+ """
+ os.makedirs(self.path, exist_ok=True)
+ zip_path = self.path / f"{self.name}.zip"
+
+ did_download = nse.utils.download_s3_file(
+ key=f"{self.name}/{self.name}.zip",
+ dst=zip_path,
+ bucket="ambiq-ai-datasets",
+ checksum="size",
+ )
+ if did_download:
+ with zipfile.ZipFile(zip_path, "r") as zf:
+ zf.extractall(self.path)
+
+ def split_train_test_patients(
+ self,
+ patient_ids: npt.NDArray,
+ test_size: float,
+ label_map: dict[int, int] | None = None,
+ label_type: str | None = None,
+ ) -> list[list[int]]:
+ """Perform train/test split on patients for given task.
+
+ Args:
+ patient_ids (npt.NDArray): Patient Ids
+ test_size (float): Test size
+ label_map (dict[int, int], optional): Label map. Defaults to None.
+ label_type (str, optional): Label type. Defaults to None.
+
+ Returns:
+ list[list[int]]: Train and test sets of patient ids
+ """
+ stratify = None
+
+ if label_map is not None and label_type is not None:
+ # Use stratified split for rhythm task
+ patients_labels = self.get_patients_labels(patient_ids, label_map=label_map, label_type=label_type)
+ # Select random label for stratification or -1 if no labels
+ stratify = np.array([random.choice(x) if len(x) > 0 else -1 for x in patients_labels])
+ # Remove patients w/o labels
+ neg_mask = stratify == -1
+ stratify = stratify[~neg_mask]
+ patient_ids = patient_ids[~neg_mask]
+ num_neg = neg_mask.sum()
+ if num_neg > 0:
+ logger.debug(f"Removed {num_neg} patients w/ no target class")
+ # END IF
+ # END IF
+
+ return sklearn.model_selection.train_test_split(
+ patient_ids,
+ test_size=test_size,
+ shuffle=True,
+ stratify=stratify,
+ )
+
+ def filter_patients_for_labels(
+ self,
+ patient_ids: npt.NDArray,
+ label_map: dict[int, int] | None = None,
+ label_type: str | None = None,
+ ) -> npt.NDArray:
+ """Filter patients based on labels.
+ Useful to remove patients w/o labels for task to speed up data loading.
+
+ Args:
+ patient_ids (npt.NDArray): Patient ids
+ label_map (dict[int, int], optional): Label map. Defaults to None.
+ label_type (str, optional): Label type. Defaults to None.
+
+ Returns:
+ npt.NDArray: Filtered patient ids
+ """
+ if label_map is None or label_type is None:
+ return patient_ids
+
+ patients_labels = self.get_patients_labels(patient_ids, label_map, label_type)
+ # Find any patient with empty list
+ label_mask = np.array([len(x) > 0 for x in patients_labels])
+ neg_mask = label_mask == -1
+ num_neg = neg_mask.sum()
+ if num_neg > 0:
+ logger.debug(f"Removed {num_neg} of {patient_ids.size} patients w/ no target class")
+ return patient_ids[~neg_mask]
+
+ def get_patients_labels(
+ self,
+ patient_ids: npt.NDArray,
+ label_map: dict[int, int],
+ label_type: str = "rhythm",
+ ) -> list[list[int]]:
+ """Get class labels for each patient
+
+ Args:
+ patient_ids (npt.NDArray): Patient ids
+ label_map (dict[int, int]): Label map
+ label_type (str, optional): Label type. Defaults to "rhythm".
+
+ Returns:
+ list[list[int]]: List of class labels per patient
+
+ """
+ ids = patient_ids.tolist()
+ func = functools.partial(self.get_patient_labels, label_map=label_map, label_type=label_type)
+ pts_labels = process_map(func, ids)
+ return pts_labels
+
+ def get_patient_labels(self, patient_id: int, label_map: dict[int, int], label_type: str = "rhythm") -> list[int]:
+ """Get class labels for patient
+
+ Args:
+ patient_id (int): Patient id
+ label_map (dict[int, int]): Label map
+ label_type (str, optional): Label type. Defaults to "rhythm".
+
+ Returns:
+ list[int]: List of class labels
+
+ """
+ label_key = self.label_key(label_type)
+ with self.patient_data(patient_id) as pt:
+ mask = pt[label_key][:]
+ labels = np.unique(mask)
+ labels: list[int] = [label_map[lbl] for lbl in labels if label_map.get(lbl, -1) != -1]
+ # END WITH
+ return list(labels)
diff --git a/heartkit/datasets/lsad.py b/heartkit/datasets/lsad.py
index 7b3cbf0f..72fc7050 100644
--- a/heartkit/datasets/lsad.py
+++ b/heartkit/datasets/lsad.py
@@ -1,12 +1,10 @@
import contextlib
import functools
-import logging
import os
import zipfile
import random
from collections.abc import Iterable
from enum import IntEnum
-from multiprocessing import Pool
from typing import Generator
import h5py
@@ -15,13 +13,13 @@
import physiokit as pk
import sklearn.model_selection
from tqdm import tqdm
+from tqdm.contrib.concurrent import process_map
+import neuralspot_edge as nse
-from ..utils import download_file
from .dataset import HKDataset
-from .defines import PatientGenerator
-from .utils import download_s3_file
+from .defines import PatientGenerator, PatientData
-logger = logging.getLogger(__name__)
+logger = nse.utils.setup_logger(__name__)
class LsadScpCode(IntEnum):
@@ -174,12 +172,10 @@ class LsadDataset(HKDataset):
def __init__(
self,
- ds_path: os.PathLike,
leads: list[int] | None = None,
+ **kwargs,
) -> None:
- super().__init__(
- ds_path=ds_path,
- )
+ super().__init__(**kwargs)
self.leads = leads or list(LsadLeadsMap.values())
@property
@@ -209,7 +205,7 @@ def patient_ids(self) -> npt.NDArray:
Returns:
npt.NDArray: patient IDs
"""
- pts = np.array([int(p.stem) for p in self.ds_path.glob("*.h5")])
+ pts = np.array([int(p.stem) for p in self.path.glob("*.h5")])
pts.sort()
return pts
@@ -249,17 +245,31 @@ def label_key(self, label_type: str = "scp") -> str:
raise ValueError(f"Invalid label type: {label_type}")
@contextlib.contextmanager
- def patient_data(self, patient_id: int) -> Generator[h5py.Group, None, None]:
+ def patient_data(self, patient_id: int) -> Generator[PatientData, None, None]:
"""Get patient data
Args:
patient_id (int): Patient ID
Returns:
- Generator[h5py.Group, None, None]: Patient data
+ Generator[PatientData, None, None]: Patient data
"""
- with h5py.File(self.ds_path / f"{self._pt_key(patient_id)}.h5", mode="r") as h5:
- yield h5
+ pt_key = self._pt_key(patient_id)
+ pt_path = self.path / f"{pt_key}.h5"
+ if self.cacheable:
+ if pt_key not in self._cached_data:
+ pt_data = {}
+ with h5py.File(pt_path, mode="r") as h5:
+ pt_data["data"] = h5["data"][:]
+ pt_data[self.label_key("scp")] = h5[self.label_key("scp")][:]
+ self._cached_data[pt_key] = pt_data
+ # END IF
+ yield self._cached_data[pt_key]
+ else:
+ with h5py.File(pt_path, mode="r") as h5:
+ yield h5
+ # END WITH
+ # END IF
def signal_generator(
self,
@@ -271,10 +281,10 @@ def signal_generator(
"""Generate random frames.
Args:
- patient_generator (PatientGenerator): Patient Generator
+ patient_generator (PatientGenerator): Generator that yields patient data.
frame_size (int): Frame size
samples_per_patient (int, optional): Samples per patient. Defaults to 1.
- target_rate (int, optional): Target rate. Defaults to None.
+ target_rate (int | None, optional): Target rate. Defaults to None.
Returns:
Generator[npt.NDArray, None, None]: Generator of input data
@@ -282,10 +292,10 @@ def signal_generator(
if target_rate is None:
target_rate = self.sampling_rate
- input_size = int(np.round((self.sampling_rate / target_rate) * frame_size))
+ input_size = int(np.ceil((self.sampling_rate / target_rate) * frame_size))
for pt in patient_generator:
with self.patient_data(pt) as h5:
- data: h5py.Dataset = h5["data"][:]
+ data = h5["data"][:]
# END WITH
for _ in range(samples_per_patient):
lead = random.choice(self.leads)
@@ -294,6 +304,7 @@ def signal_generator(
x = np.nan_to_num(x).astype(np.float32)
if self.sampling_rate != target_rate:
x = pk.signal.resample_signal(x, self.sampling_rate, target_rate, axis=0)
+ x = x[:frame_size]
# END IF
yield x
# END FOR
@@ -342,7 +353,7 @@ def signal_label_generator(
num_per_tgt = int(max(1, samples_per_patient / num_classes))
samples_per_tgt = num_classes * [num_per_tgt]
- input_size = int(np.round((self.sampling_rate / target_rate) * frame_size))
+ input_size = int(np.ceil((self.sampling_rate / target_rate) * frame_size))
for pt in patient_generator:
# 1. Grab patient scp label (fixed for all samples)
@@ -395,6 +406,8 @@ def signal_label_generator(
# Resample if needed
if self.sampling_rate != target_rate:
x = pk.signal.resample_signal(x, self.sampling_rate, target_rate, axis=0)
+ x = x[:frame_size] # truncate to frame size
+ x = np.reshape(x, (frame_size, 1))
yield x, y
# END FOR
# END FOR
@@ -480,6 +493,8 @@ def get_patients_labels(
Args:
patient_ids (npt.NDArray): Patient ids
+ label_map (dict[int, int]): Label map
+ label_type (str, optional): Label type. Defaults to "scp".
Returns:
list[list[int]]: List of class labels per patient
@@ -487,8 +502,7 @@ def get_patients_labels(
"""
ids = patient_ids.tolist()
func = functools.partial(self.get_patient_labels, label_map=label_map, label_type=label_type)
- with Pool() as pool:
- pts_labels = list(pool.imap(func, ids))
+ pts_labels = process_map(func, ids)
return pts_labels
def get_patient_labels(self, patient_id: int, label_map: dict[int, int], label_type: str = "scp") -> list[int]:
@@ -496,6 +510,8 @@ def get_patient_labels(self, patient_id: int, label_map: dict[int, int], label_t
Args:
patient_id (int): Patient id
+ label_map (dict[int, int]): Label map
+ label_type (str, optional): Label type. Defaults to "scp".
Returns:
list[int]: List of class labels
@@ -516,10 +532,10 @@ def download(self, num_workers: int | None = None, force: bool = False):
num_workers (int | None, optional): # parallel workers. Defaults to None.
force (bool, optional): Force redownload. Defaults to False.
"""
- os.makedirs(self.ds_path, exist_ok=True)
- zip_path = self.ds_path / f"{self.name}.zip"
+ os.makedirs(self.path, exist_ok=True)
+ zip_path = self.path / f"{self.name}.zip"
- did_download = download_s3_file(
+ did_download = nse.utils.download_s3_file(
key=f"{self.name}/{self.name}.zip",
dst=zip_path,
bucket="ambiq-ai-datasets",
@@ -527,7 +543,7 @@ def download(self, num_workers: int | None = None, force: bool = False):
)
if did_download:
with zipfile.ZipFile(zip_path, "r") as zf:
- zf.extractall(self.ds_path)
+ zf.extractall(self.path)
def download_raw_dataset(self, num_workers: int | None = None, force: bool = False):
"""Downloads full dataset zipfile and converts into individial patient HDF5 files.
@@ -541,14 +557,14 @@ def download_raw_dataset(self, num_workers: int | None = None, force: bool = Fal
"https://www.physionet.org/static/published-projects/ecg-arrhythmia/"
"a-large-scale-12-lead-electrocardiogram-database-for-arrhythmia-study-1.0.0.zip"
)
- ds_zip_path = self.ds_path / "lsad.zip"
- os.makedirs(self.ds_path, exist_ok=True)
+ ds_zip_path = self.path / "lsad.zip"
+ os.makedirs(self.path, exist_ok=True)
if os.path.exists(ds_zip_path) and not force:
logger.warning(
f"Zip file already exists. Please delete or set `force` flag to redownload. PATH={ds_zip_path}"
)
else:
- download_file(ds_url, ds_zip_path, progress=True)
+ nse.utils.download_file(ds_url, ds_zip_path, progress=True)
# 2. Extract and convert patient ECG data to H5 files
logger.debug("Processing LSAD patient data")
@@ -600,7 +616,7 @@ def _convert_dataset_zip_to_hdf5(
try:
# Extract patient ID by remove JS prefix and .mat suffix
pt_id = os.path.basename(zp_rec_name).removeprefix("JS").removesuffix(".mat")
- pt_path = self.ds_path / f"{pt_id}.h5"
+ pt_path = self.path / f"{pt_id}.h5"
with tempfile.TemporaryDirectory() as tmpdir:
rec_fpath = os.path.join(tmpdir, f"JS{pt_id}")
diff --git a/heartkit/datasets/ludb.py b/heartkit/datasets/ludb.py
index fe2cab14..0ca5c232 100644
--- a/heartkit/datasets/ludb.py
+++ b/heartkit/datasets/ludb.py
@@ -1,12 +1,10 @@
import contextlib
import functools
-import logging
import os
import random
import tempfile
import zipfile
from enum import IntEnum
-from multiprocessing import Pool
from pathlib import Path
from typing import Generator
@@ -14,14 +12,13 @@
import numpy as np
import numpy.typing as npt
import physiokit as pk
-from tqdm import tqdm
+from tqdm.contrib.concurrent import process_map
+import neuralspot_edge as nse
-from ..utils import download_file
from .dataset import HKDataset
-from .defines import PatientGenerator
-from .utils import download_s3_file
+from .defines import PatientGenerator, PatientData
-logger = logging.getLogger(__name__)
+logger = nse.utils.setup_logger(__name__)
LudbSymbolMap = {
"o": 0, # Other
@@ -69,12 +66,10 @@ class LudbDataset(HKDataset):
def __init__(
self,
- ds_path: os.PathLike,
leads: list[int] | None = None,
+ **kwargs,
) -> None:
- super().__init__(
- ds_path=ds_path,
- )
+ super().__init__(**kwargs)
self.leads = leads or list(LudbLeadsMap.values())
@property
@@ -127,17 +122,33 @@ def _pt_key(self, patient_id: int):
return f"p{patient_id:05d}"
@contextlib.contextmanager
- def patient_data(self, patient_id: int) -> Generator[h5py.Group, None, None]:
+ def patient_data(self, patient_id: int) -> Generator[PatientData, None, None]:
"""Get patient data
Args:
patient_id (int): Patient ID
Returns:
- Generator[h5py.Group, None, None]: Patient data
+ Generator[PatientData, None, None]: Patient data
"""
- with h5py.File(self.ds_path / f"{self._pt_key(patient_id)}.h5", mode="r") as h5:
- yield h5
+ pt_key = self._pt_key(patient_id)
+ pt_path = self.path / f"{pt_key}.h5"
+ if self.cacheable:
+ if pt_key not in self._cached_data:
+ pt_data = {}
+ with h5py.File(pt_path, mode="r") as h5:
+ pt_data["data"] = h5["data"][:]
+ pt_data["segmentations"] = h5["segmentations"][:]
+ pt_data["fiducials"] = h5["fiducials"][:]
+ # END WITH
+ self._cached_data[pt_key] = pt_data
+ # END IF
+ yield self._cached_data[pt_key]
+ else:
+ with h5py.File(pt_path, mode="r") as h5:
+ yield h5
+ # END WITH
+ # END IF
def signal_generator(
self,
@@ -149,10 +160,10 @@ def signal_generator(
"""Generate random frames.
Args:
- patient_generator (PatientGenerator): Patient generator
+ patient_generator (PatientGenerator): Generator that yields patient data.
frame_size (int): Frame size
- samples_per_patient (int, optional): # samples per patient. Defaults to 1.
- target_rate (int | None, optional): Target sampling rate. Defaults to None.
+ samples_per_patient (int, optional): Samples per patient. Defaults to 1.
+ target_rate (int | None, optional): Target rate. Defaults to None.
Returns:
Generator[npt.NDArray, None, None]: Generator of input data of shape (frame_size, 1)
@@ -161,7 +172,7 @@ def signal_generator(
if target_rate is None:
target_rate = self.sampling_rate
- input_size = int(np.round((self.sampling_rate / target_rate) * frame_size))
+ input_size = int(np.ceil((self.sampling_rate / target_rate) * frame_size))
for pt in patient_generator:
with self.patient_data(pt) as h5:
@@ -174,6 +185,7 @@ def signal_generator(
x = np.nan_to_num(x).astype(np.float32)
if self.sampling_rate != target_rate:
x = pk.signal.resample_signal(x, self.sampling_rate, target_rate, axis=0)
+ x = x[:frame_size]
# END IF
yield x
# END FOR
@@ -205,10 +217,10 @@ def download(self, num_workers: int | None = None, force: bool = False):
num_workers (int | None, optional): # parallel workers. Defaults to None.
force (bool, optional): Force redownload. Defaults to False.
"""
- os.makedirs(self.ds_path, exist_ok=True)
- zip_path = self.ds_path / f"{self.name}.zip"
+ os.makedirs(self.path, exist_ok=True)
+ zip_path = self.path / f"{self.name}.zip"
- did_download = download_s3_file(
+ did_download = nse.utils.download_s3_file(
key=f"{self.name}/{self.name}.zip",
dst=zip_path,
bucket="ambiq-ai-datasets",
@@ -216,7 +228,7 @@ def download(self, num_workers: int | None = None, force: bool = False):
)
if did_download:
with zipfile.ZipFile(zip_path, "r") as zf:
- zf.extractall(self.ds_path)
+ zf.extractall(self.path)
def download_raw_dataset(self, num_workers: int | None = None, force: bool = False):
"""Downloads full dataset zipfile and converts into individial patient HDF5 files.
@@ -230,14 +242,14 @@ def download_raw_dataset(self, num_workers: int | None = None, force: bool = Fal
"https://physionet.org/static/published-projects/ludb/"
"lobachevsky-university-electrocardiography-database-1.0.1.zip"
)
- ds_zip_path = self.ds_path / "ludb.zip"
- os.makedirs(self.ds_path, exist_ok=True)
+ ds_zip_path = self.path / "ludb.zip"
+ os.makedirs(self.path, exist_ok=True)
if os.path.exists(ds_zip_path) and not force:
logger.warning(
f"Zip file already exists. Please delete or set `force` flag to redownload. PATH={ds_zip_path}"
)
else:
- download_file(ds_url, ds_zip_path, progress=True)
+ nse.utils.download_file(ds_url, ds_zip_path, progress=True)
# 2. Extract and convert patient ECG data to H5 files
logger.debug("Generating LUDB patient data")
@@ -263,18 +275,16 @@ def convert_dataset_zip_to_hdf5(
patient_ids = self.patient_ids
subdir = "lobachevsky-university-electrocardiography-database-1.0.1"
- with Pool(processes=num_workers) as pool, tempfile.TemporaryDirectory() as tmpdir, zipfile.ZipFile(
- zip_path, mode="r"
- ) as zp:
+ with tempfile.TemporaryDirectory() as tmpdir, zipfile.ZipFile(zip_path, mode="r") as zp:
ludb_dir = Path(tmpdir, "ludb")
zp.extractall(ludb_dir)
f = functools.partial(
self.convert_pt_wfdb_to_hdf5,
src_path=ludb_dir / subdir / "data",
- dst_path=self.ds_path,
+ dst_path=self.path,
force=force,
)
- _ = list(tqdm(pool.imap(f, patient_ids), total=len(patient_ids)))
+ _ = process_map(f, patient_ids)
# END WITH
def convert_pt_wfdb_to_hdf5(
diff --git a/heartkit/datasets/nstdb.py b/heartkit/datasets/nstdb.py
index f63017a6..b9d72f4f 100644
--- a/heartkit/datasets/nstdb.py
+++ b/heartkit/datasets/nstdb.py
@@ -1,4 +1,3 @@
-import logging
import os
from pathlib import Path
@@ -6,17 +5,22 @@
import numpy as np
import numpy.typing as npt
import physiokit as pk
+import neuralspot_edge as nse
-logger = logging.getLogger(__name__)
+logger = nse.utils.setup_logger(__name__)
class NstdbNoise:
- """Noise stress test database (NSTDB) noise generator."""
-
def __init__(
self,
target_rate: int,
):
+ """Noise stress test database (NSTDB) noise generator.
+
+ Args:
+ target_rate (int): Target rate in Hz
+ """
+
self.target_rate = target_rate
self._noises: dict[str, npt.NDArray] | None = None
diff --git a/heartkit/datasets/syntheticppg.py b/heartkit/datasets/ppg_synthetic.py
similarity index 63%
rename from heartkit/datasets/syntheticppg.py
rename to heartkit/datasets/ppg_synthetic.py
index 1585e08a..3fec3839 100644
--- a/heartkit/datasets/syntheticppg.py
+++ b/heartkit/datasets/ppg_synthetic.py
@@ -1,24 +1,25 @@
+import tempfile
import contextlib
-import io
-import logging
-import os
from typing import Generator
+from pathlib import Path
import h5py
import numpy as np
import numpy.typing as npt
import physiokit as pk
from pydantic import BaseModel, Field
+import neuralspot_edge as nse
+from tqdm.contrib.concurrent import process_map
from .dataset import HKDataset
-from .defines import PatientGenerator
+from .defines import PatientGenerator, PatientData
from .nstdb import NstdbNoise
-logger = logging.getLogger(__name__)
+logger = nse.utils.setup_logger(__name__)
-class SyntheticPpgParams(BaseModel, extra="allow"):
- """PPG Synthetic parameters"""
+class PpgSyntheticParams(BaseModel, extra="allow"):
+ """PPG Synthetic signal generator parameters"""
sample_rate: float = Field(500, description="Signal sample rate (Hz)")
duration: int = Field(10, description="Signal duration in sec")
@@ -28,33 +29,62 @@ class SyntheticPpgParams(BaseModel, extra="allow"):
noise_multiplier: tuple[float, float] = Field((0, 0), description="Noise multiplier range")
-class SyntheticPpgDataset(HKDataset):
- """Synthetic PPG dataset"""
-
+class PpgSyntheticDataset(HKDataset):
def __init__(
self,
- ds_path: os.PathLike,
num_pts: int = 250,
params: dict | None = None,
+ path: str = Path(tempfile.gettempdir()) / "ppg-synthetic",
+ **kwargs,
) -> None:
- super().__init__(
- ds_path=ds_path,
+ """PPG synthetic dataset creates 1-lead PPG signal using PhysioKit.
+
+ Args:
+ num_pts (int, optional): Number of patients. Defaults to 250.
+ params (dict | None, optional): PPG synthetic parameters (PpgSyntheticParams). Defaults to None.
+ path (str, optional): Path to dataset. Defaults to Path(tempfile.gettempdir()) / "ppg-synthetic".
+
+ Example:
+
+ ```python
+ import heartkit as hk
+
+ # Create synthetic PPG dataset:
+ # - 10 patients
+ # - 500 Hz sample rate
+ # - 10 sec duration
+ # - heart rate between 40 and 120 bpm
+ # - frequency modulation between 0.2 and 0.4
+ # - IBI randomness between 0.05 and 0.15
+ # - no noise
+ ds = hk.datasets.PpgSyntheticDataset(
+ num_pts=10,
+ params=dict(
+ sample_rate=500,
+ duration=10,
+ heart_rate=(40, 120),
+ frequency_modulation=(0.2, 0.4),
+ ibi_randomness=(0.05, 0.15),
+ noise_multiplier=(0, 0),
+ )
)
+
+ with ds.patient_data[ds.patient_ids[0]] as pt:
+ ppg = pt["data"][:]
+ segs = pt["segmentations"][:]
+ fids = pt["fiducials"][:]
+ # END WITH
+ ```
+ """
+ super().__init__(path=path, **kwargs)
self._noise_gen = None
self._num_pts = num_pts
- self.params = SyntheticPpgParams(**params or {})
- self._cache: dict[str, io.BytesIO] = {}
- os.makedirs(self.ds_path, exist_ok=True)
+ self.params = PpgSyntheticParams(**params or {})
@property
def name(self) -> str:
"""Dataset name"""
- return "syntheticppg"
-
- @property
- def cachable(self) -> bool:
- """If dataset supports file caching."""
- return True
+ return "ppg-synthetic"
@property
def sampling_rate(self) -> int:
@@ -102,47 +132,42 @@ def pt_key(self, patient_id: int):
"""Get patient key"""
return f"{patient_id:05d}"
+ def load_patient_data(self, patient_id: int):
+ ppg, segs, fids = self._synthesize_signal(
+ frame_size=int(self.params.duration * self.sampling_rate), target_rate=self.sampling_rate
+ )
+ pt_data = {
+ "data": ppg,
+ "segmentations": segs,
+ "fiducials": fids,
+ }
+ return pt_data
+
+ def build_cache(self):
+ """Build cache"""
+ logger.info(f"Creating synthetic dataset cache with {self._num_pts} patients")
+ pts_data = process_map(self.load_patient_data, self.patient_ids)
+ self._cached_data = {self.pt_key(i): pt_data for i, pt_data in enumerate(pts_data)}
+
@contextlib.contextmanager
- def patient_data(self, patient_id: int) -> Generator[h5py.Group, None, None]:
+ def patient_data(self, patient_id: int) -> Generator[PatientData, None, None]:
"""Get patient data
Args:
patient_id (int): Patient ID
Returns:
- Generator[h5py.Group, None, None]: Patient data
+ Generator[PatientData, None, None]: Patient data
"""
- ppg, segs, fids = self._synthesize_signal(
- frame_size=int(self.params.duration * self.sampling_rate), target_rate=self.sampling_rate
- )
- fp = io.BytesIO()
- with h5py.File(fp, mode="w") as h5:
- h5.create_dataset("data", data=ppg)
- h5.create_dataset("segmentations", data=segs)
- h5.create_dataset("fiducials", data=fids)
- # END WITH
- fp.seek(0)
- with h5py.File(fp, mode="r") as h5:
- yield h5
-
- # pt_key = self.pt_key(patient_id)
- # if pt_key not in self._cache:
- # ppg, segs, fids = self._synthesize_signal(
- # frame_size=int(self.params.duration * self.sampling_rate), target_rate=self.sampling_rate
- # )
- # fp = io.BytesIO()
- # with h5py.File(fp, mode="w") as h5:
- # h5.create_dataset("data", data=ppg)
- # h5.create_dataset("segmentations", data=segs)
- # h5.create_dataset("fiducials", data=fids)
- # # END WITH
- # fp.seek(0)
- # self._cache[pt_key] = fp
- # # END IF
-
- # with h5py.File(self._cache[pt_key], mode="r") as h5:
- # yield h5
- # # END WITH
+ pt_key = self.pt_key(patient_id)
+ if self.cacheable:
+ if pt_key not in self._cached_data:
+ self.build_cache()
+ yield self._cached_data[pt_key]
+ else:
+ pt_data = self.load_patient_data(patient_id)
+ yield pt_data
+ # END IF
def signal_generator(
self,
@@ -154,8 +179,10 @@ def signal_generator(
"""Generate frames using patient generator.
Args:
- patient_generator (PatientGenerator): Generator that yields a tuple of patient id and patient data.
- samples_per_patient (int): Samples per patient.
+ patient_generator (PatientGenerator): Generator that yields patient data.
+ frame_size (int): Frame size
+ samples_per_patient (int, optional): Samples per patient. Defaults to 1.
+ target_rate (int | None, optional): Target rate. Defaults to None.
Returns:
SampleGenerator: Generator of input data of shape (frame_size, 1)
@@ -163,7 +190,7 @@ def signal_generator(
if target_rate is None:
target_rate = self.sampling_rate
- input_size = int(np.round((self.sampling_rate / target_rate) * frame_size))
+ input_size = int(np.ceil((self.sampling_rate / target_rate) * frame_size))
for pt in patient_generator:
with self.patient_data(pt) as h5:
@@ -176,6 +203,7 @@ def signal_generator(
x = self.add_noise(x)
if self.sampling_rate != target_rate:
x = pk.signal.resample_signal(x, self.sampling_rate, target_rate, axis=0)
+ x = x[:frame_size]
# END IF
yield x
# END FOR
@@ -219,6 +247,7 @@ def _synthesize_signal(
Args:
frame_size (int): Frame size
+ target_rate (float | None, optional): Target rate. Defaults to None.
Returns:
tuple[npt.NDArray, npt.NDArray, npt.NDArray]: signal, segments, fiducials
@@ -227,7 +256,7 @@ def _synthesize_signal(
frequency_modulation = np.random.uniform(
self.params.frequency_modulation[0], self.params.frequency_modulation[1]
)
- frequency_modulation = min(frequency_modulation, 1 - 0.3 / (60 / heart_rate)) # Must be at least 300 ms IBI
+ frequency_modulation = min(frequency_modulation, 1 - 0.35 / (60 / heart_rate)) # Must be at least 300 ms IBI
ibi_randomness = np.random.uniform(self.params.ibi_randomness[0], self.params.ibi_randomness[1])
ppg, segs, fids = pk.ppg.synthesize(
diff --git a/heartkit/datasets/preprocessing.py b/heartkit/datasets/preprocessing.py
deleted file mode 100644
index c3b69f2d..00000000
--- a/heartkit/datasets/preprocessing.py
+++ /dev/null
@@ -1,28 +0,0 @@
-import numpy.typing as npt
-import physiokit as pk
-
-from ..defines import PreprocessParams
-
-
-def preprocess_pipeline(x: npt.NDArray, preprocesses: list[PreprocessParams], sample_rate: float) -> npt.NDArray:
- """Apply preprocessing pipeline
-
- Args:
- x (npt.NDArray): Signal
- preprocesses (list[PreprocessParams]): Preprocessing pipeline
- sample_rate (float): Sampling rate in Hz.
-
- Returns:
- npt.NDArray: Preprocessed signal
- """
- for preprocess in preprocesses:
- match preprocess.name:
- case "filter":
- x = pk.signal.filter_signal(x, sample_rate=sample_rate, **preprocess.params)
- case "znorm":
- x = pk.signal.normalize_signal(x, **preprocess.params)
- case _:
- raise ValueError(f"Unknown preprocess '{preprocess.name}'")
- # END MATCH
- # END FOR
- return x
diff --git a/heartkit/datasets/ptbxl.py b/heartkit/datasets/ptbxl.py
index d29f873e..2109e1f4 100644
--- a/heartkit/datasets/ptbxl.py
+++ b/heartkit/datasets/ptbxl.py
@@ -1,12 +1,10 @@
import contextlib
import functools
-import logging
import os
import zipfile
import random
from collections.abc import Iterable
from enum import IntEnum
-from multiprocessing import Pool
from typing import Generator
import h5py
@@ -15,13 +13,13 @@
import physiokit as pk
import sklearn.model_selection
from tqdm import tqdm
+from tqdm.contrib.concurrent import process_map
+import neuralspot_edge as nse
-from ..utils import download_file
from .dataset import HKDataset
-from .defines import PatientGenerator
-from .utils import download_s3_file
+from .defines import PatientGenerator, PatientData
-logger = logging.getLogger(__name__)
+logger = nse.utils.setup_logger(__name__)
class PtbxlScpCode(IntEnum):
@@ -199,16 +197,14 @@ class PtbxlScpCode(IntEnum):
class PtbxlDataset(HKDataset):
- """PTBXL dataset"""
+ def __init__(self, leads: list[int] | None = None, **kwargs) -> None:
+ """PTBXL dataset consists of 21837 clinical 12-lead ECGs from 18885 patients.
- def __init__(
- self,
- ds_path: os.PathLike,
- leads: list[int] | None = None,
- ) -> None:
- super().__init__(
- ds_path=ds_path,
- )
+ Args:
+ leads (list[int] | None, optional): Leads to use. Defaults to None.
+
+ """
+ super().__init__(**kwargs)
self.leads = leads or list(range(12))
self._data_cache: dict[str, np.ndarray] = {}
@@ -319,17 +315,40 @@ def label_key(self, label_type: str = "scp") -> str:
raise ValueError(f"Invalid label type: {label_type}")
@contextlib.contextmanager
- def patient_data(self, patient_id: int) -> Generator[h5py.Group, None, None]:
+ def patient_data(self, patient_id: int) -> Generator[PatientData, None, None]:
"""Get patient data
+ !!! note
+ If cacheable, data is cached in memory and returned as dict
+ Otherwise, data is provided as HDF5 objects
+
+ Patient Data Format:
+ - data: ECG data of shape (12, N)
+ - slabels: SCP labels of shape (N, 2)
+ - blabels: Beat labels of shape (N, 2)
+
Args:
patient_id (int): Patient ID
Returns:
- Generator[h5py.Group, None, None]: Patient data
+ Generator[PatientData, None, None]: Patient data
"""
- with h5py.File(self.ds_path / f"{self._pt_key(patient_id)}.h5", mode="r") as h5:
- yield h5
+ pt_path = self.path / f"{self._pt_key(patient_id)}.h5"
+ if self.cacheable:
+ if patient_id not in self._cached_data:
+ pt_data = {}
+ with h5py.File(pt_path, mode="r") as h5:
+ pt_data["data"] = h5["data"][:]
+ pt_data["slabels"] = h5["slabels"][:]
+ pt_data["blabels"] = h5["blabels"][:]
+ self._cached_data[patient_id] = pt_data
+ # END IF
+ yield self._cached_data[patient_id]
+ else:
+ with h5py.File(pt_path, mode="r") as h5:
+ yield h5
+ # END WITH
+ # END IF
def signal_generator(
self,
@@ -341,9 +360,10 @@ def signal_generator(
"""Generate random frames.
Args:
- patient_generator (PatientGenerator): Generator that yields a tuple of patient id and patient data.
- Patient data may contain only signals, since labels are not used.
- samples_per_patient (int): Samples per patient.
+ patient_generator (PatientGenerator): Generator that yields patient data.
+ frame_size (int): Frame size
+ samples_per_patient (int, optional): Samples per patient. Defaults to 1.
+ target_rate (int | None, optional): Target rate. Defaults to None.
Returns:
Generator[npt.NDArray, None, None]: Generator of input data of shape (frame_size, 1)
@@ -351,7 +371,7 @@ def signal_generator(
if target_rate is None:
target_rate = self.sampling_rate
- input_size = int(np.round((self.sampling_rate / target_rate) * frame_size))
+ input_size = int(np.ceil((self.sampling_rate / target_rate) * frame_size))
for pt in patient_generator:
with self.patient_data(pt) as h5:
@@ -364,6 +384,7 @@ def signal_generator(
x = np.nan_to_num(x).astype(np.float32)
if self.sampling_rate != target_rate:
x = pk.signal.resample_signal(x, self.sampling_rate, target_rate, axis=0)
+ x = x[:frame_size] # truncate to frame size
# END IF
yield x
# END FOR
@@ -413,7 +434,7 @@ def signal_label_generator(
samples_per_tgt = num_classes * [num_per_tgt]
# END IF
- input_size = int(np.round((self.sampling_rate / target_rate) * frame_size))
+ input_size = int(np.ceil((self.sampling_rate / target_rate) * frame_size))
for pt in patient_generator:
# 1. Grab patient scp label (fixed for all samples)
@@ -470,6 +491,8 @@ def signal_label_generator(
# Resample if needed
if self.sampling_rate != target_rate:
x = pk.signal.resample_signal(x, self.sampling_rate, target_rate, axis=0)
+ x = x[:frame_size] # truncate to frame size
+ x = np.reshape(x, (frame_size, 1))
yield x, y
# END FOR
# END FOR
@@ -564,8 +587,7 @@ def get_patients_labels(
"""
ids = patient_ids.tolist()
func = functools.partial(self.get_patient_labels, label_map=label_map, label_type=label_type)
- with Pool() as pool:
- pts_labels = list(pool.imap(func, ids))
+ pts_labels = process_map(func, ids)
return pts_labels
def get_patient_scp_codes(self, patient_id: int) -> list[int]:
@@ -607,10 +629,10 @@ def download(self, num_workers: int | None = None, force: bool = False):
num_workers (int | None, optional): # parallel workers. Defaults to None.
force (bool, optional): Force redownload. Defaults to False.
"""
- os.makedirs(self.ds_path, exist_ok=True)
- zip_path = self.ds_path / f"{self.name}.zip"
+ os.makedirs(self.path, exist_ok=True)
+ zip_path = self.path / f"{self.name}.zip"
- did_download = download_s3_file(
+ did_download = nse.utils.download_s3_file(
key=f"{self.name}/{self.name}.zip",
dst=zip_path,
bucket="ambiq-ai-datasets",
@@ -618,7 +640,7 @@ def download(self, num_workers: int | None = None, force: bool = False):
)
if did_download:
with zipfile.ZipFile(zip_path, "r") as zf:
- zf.extractall(self.ds_path)
+ zf.extractall(self.path)
def download_raw_dataset(self, num_workers: int | None = None, force: bool = False):
"""Downloads full dataset zipfile and converts into individial patient HDF5 files.
@@ -632,14 +654,14 @@ def download_raw_dataset(self, num_workers: int | None = None, force: bool = Fal
"https://www.physionet.org/static/published-projects/ptb-xl/"
"ptb-xl-a-large-publicly-available-electrocardiography-dataset-1.0.2.zip"
)
- ds_zip_path = self.ds_path / "ptbxl.zip"
- os.makedirs(self.ds_path, exist_ok=True)
+ ds_zip_path = self.path / "ptbxl.zip"
+ os.makedirs(self.path, exist_ok=True)
if os.path.exists(ds_zip_path) and not force:
logger.warning(
f"Zip file already exists. Please delete or set `force` flag to redownload. PATH={ds_zip_path}"
)
else:
- download_file(ds_url, ds_zip_path, progress=True)
+ nse.utils.download_file(ds_url, ds_zip_path, progress=True)
# 2. Extract and convert patient ECG data to H5 files
logger.debug("Processing PTB-XL patient data")
@@ -683,7 +705,7 @@ def _convert_dataset_zip_to_hdf5(
zp_root = "ptb-xl-a-large-publicly-available-electrocardiography-dataset-1.0.2"
# scp_df = pd.read_csv(io.BytesIO(zp.read(os.path.join(zp_root, "scp_statements.csv"))))
- with open(self.ds_path / "scp_statements.csv", "wb") as fp:
+ with open(self.path / "scp_statements.csv", "wb") as fp:
fp.write(zp.read(os.path.join(zp_root, "scp_statements.csv")))
db_df = pd.read_csv(io.BytesIO(zp.read(os.path.join(zp_root, "ptbxl_database.csv"))))
@@ -694,7 +716,7 @@ def _convert_dataset_zip_to_hdf5(
for patient in tqdm(patient_ids, desc="Converting"):
# logger.debug(f"Processing patient {patient}")
pt_id = self._pt_key(patient)
- pt_path = self.ds_path / f"{pt_id}.h5"
+ pt_path = self.path / f"{pt_id}.h5"
pt_info = db_df[db_df.ecg_id == patient]
if len(pt_info) == 0:
diff --git a/heartkit/datasets/qtdb.py b/heartkit/datasets/qtdb.py
index bd121cd6..4f26fa19 100644
--- a/heartkit/datasets/qtdb.py
+++ b/heartkit/datasets/qtdb.py
@@ -1,25 +1,22 @@
import contextlib
import functools
-import logging
import os
import random
import tempfile
import zipfile
-from multiprocessing import Pool
from typing import Generator
import h5py
import numpy as np
import numpy.typing as npt
import physiokit as pk
-from tqdm import tqdm
+from tqdm.contrib.concurrent import process_map
+import neuralspot_edge as nse
-from ..utils import download_file
from .dataset import HKDataset
-from .defines import PatientGenerator
-from .utils import download_s3_file
+from .defines import PatientGenerator, PatientData
-logger = logging.getLogger(__name__)
+logger = nse.utils.setup_logger(__name__)
QtdbSymbolMap = {
"o": 0, # Other
@@ -42,9 +39,9 @@ class QtdbDataset(HKDataset):
def __init__(
self,
- ds_path: os.PathLike,
+ **kwargs,
) -> None:
- super().__init__(ds_path=ds_path)
+ super().__init__(**kwargs)
@property
def name(self) -> str:
@@ -204,17 +201,33 @@ def _pt_key(self, patient_id: int):
return f"{patient_id}"
@contextlib.contextmanager
- def patient_data(self, patient_id: int) -> Generator[h5py.Group, None, None]:
+ def patient_data(self, patient_id: int) -> Generator[PatientData, None, None]:
"""Get patient data
Args:
patient_id (int): Patient ID
Returns:
- Generator[h5py.Group, None, None]: Patient data
+ Generator[PatientData, None, None]: Patient data
"""
- with h5py.File(self.ds_path / f"{self._pt_key(patient_id)}.h5", mode="r") as h5:
- yield h5
+ pt_key = self._pt_key(patient_id)
+ pt_path = self.path / f"{pt_key}.h5"
+ if self.cacheable:
+ if pt_key not in self._cached_data:
+ pt_data = {}
+ with h5py.File(pt_path, mode="r") as h5:
+ pt_data["data"] = h5["data"][:]
+ pt_data["segmentations"] = h5["segmentations"][:]
+ pt_data["fiducials"] = h5["fiducials"][:]
+ # END WITH
+ self._cached_data[pt_key] = pt_data
+ # END IF
+ yield self._cached_data[pt_key]
+ else:
+ with h5py.File(pt_path, mode="r") as h5:
+ yield h5
+ # END WITH
+ # END IF
def signal_generator(
self,
@@ -226,9 +239,10 @@ def signal_generator(
"""Generate random frames.
Args:
- patient_generator (PatientGenerator): Generator that yields a tuple of patient id and patient data.
- Patient data may contain only signals, since labels are not used.
- samples_per_patient (int): Samples per patient.
+ patient_generator (PatientGenerator): Generator that yields patient data.
+ frame_size (int): Frame size
+ samples_per_patient (int, optional): Samples per patient. Defaults to 1.
+ target_rate (int | None, optional): Target rate. Defaults to None.
Returns:
Generator[npt.NDArray, None, None]: Generator of input data of shape (frame_size, 1)
@@ -236,7 +250,7 @@ def signal_generator(
if target_rate is None:
target_rate = self.sampling_rate
- input_size = int(np.round((self.sampling_rate / target_rate) * frame_size))
+ input_size = int(np.ceil((self.sampling_rate / target_rate) * frame_size))
for pt in patient_generator:
with self.patient_data(pt) as h5:
@@ -249,6 +263,7 @@ def signal_generator(
x = np.nan_to_num(x).astype(np.float32)
if self.sampling_rate != target_rate:
x = pk.signal.resample_signal(x, self.sampling_rate, target_rate, axis=0)
+ x = x[:frame_size]
# END IF
yield x
# END FOR
@@ -279,10 +294,10 @@ def download(self, num_workers: int | None = None, force: bool = False):
num_workers (int | None, optional): # parallel workers. Defaults to None.
force (bool, optional): Force redownload. Defaults to False.
"""
- os.makedirs(self.ds_path, exist_ok=True)
- zip_path = self.ds_path / f"{self.name}.zip"
+ os.makedirs(self.path, exist_ok=True)
+ zip_path = self.path / f"{self.name}.zip"
- did_download = download_s3_file(
+ did_download = nse.utils.download_s3_file(
key=f"{self.name}/{self.name}.zip",
dst=zip_path,
bucket="ambiq-ai-datasets",
@@ -290,7 +305,7 @@ def download(self, num_workers: int | None = None, force: bool = False):
)
if did_download:
with zipfile.ZipFile(zip_path, "r") as zf:
- zf.extractall(self.ds_path)
+ zf.extractall(self.path)
def download_raw_dataset(self, num_workers: int | None = None, force: bool = False):
"""Downloads full dataset zipfile and converts into individial patient HDF5 files.
@@ -301,14 +316,14 @@ def download_raw_dataset(self, num_workers: int | None = None, force: bool = Fal
"""
logger.debug("Downloading QTDB dataset")
ds_url = "https://physionet.org/static/published-projects/qtdb/qt-database-1.0.0.zip"
- ds_zip_path = self.ds_path / "qtdb.zip"
- os.makedirs(self.ds_path, exist_ok=True)
+ ds_zip_path = self.path / "qtdb.zip"
+ os.makedirs(self.path, exist_ok=True)
if os.path.exists(ds_zip_path) and not force:
logger.warning(
f"Zip file already exists. Please delete or set `force` flag to redownload. PATH={ds_zip_path}"
)
else:
- download_file(ds_url, ds_zip_path, progress=True)
+ nse.utils.download_file(ds_url, ds_zip_path, progress=True)
# 2. Extract and convert patient ECG data to H5 files
logger.debug("Generating QT patient data")
@@ -396,17 +411,15 @@ def convert_dataset_zip_to_hdf5(
patient_ids = self.patient_ids
subdir = "qt-database-1.0.0"
- with Pool(processes=num_workers) as pool, tempfile.TemporaryDirectory() as tmpdir, zipfile.ZipFile(
- zip_path, mode="r"
- ) as zp:
+ with tempfile.TemporaryDirectory() as tmpdir, zipfile.ZipFile(zip_path, mode="r") as zp:
qtdb_dir = tmpdir / "qtdb"
zp.extractall(qtdb_dir)
f = functools.partial(
self.convert_pt_wfdb_to_hdf5,
src_path=qtdb_dir / subdir,
- dst_path=self.ds_path,
+ dst_path=self.path,
force=force,
)
- _ = list(tqdm(pool.imap(f, patient_ids), total=len(patient_ids)))
+ _ = process_map(f, patient_ids)
# END WITH
diff --git a/heartkit/datasets/utils.py b/heartkit/datasets/utils.py
deleted file mode 100644
index de977065..00000000
--- a/heartkit/datasets/utils.py
+++ /dev/null
@@ -1,374 +0,0 @@
-import functools
-import os
-import random
-from concurrent.futures import ThreadPoolExecutor, as_completed
-from pathlib import Path
-from typing import Callable, Generator, Iterable, TypeVar
-
-import boto3
-import numpy as np
-import numpy.typing as npt
-import tensorflow as tf
-from botocore import UNSIGNED
-from botocore.client import Config
-from tqdm import tqdm
-
-from ..utils import compute_checksum, setup_logger
-
-logger = setup_logger(__name__)
-
-
-def create_dataset_from_data(x: npt.NDArray, y: npt.NDArray, spec: tuple[tf.TensorSpec]) -> tf.data.Dataset:
- """Helper function to create dataset from static data
-
- Args:
- x (npt.NDArray): Numpy data
- y (npt.NDArray): Numpy labels
-
- Returns:
- tf.data.Dataset: Dataset
- """
- return tf.data.Dataset.zip((tf.data.Dataset.from_tensor_slices(x), tf.data.Dataset.from_tensor_slices(y)))
-
-
-T = TypeVar("T")
-K = TypeVar("K")
-
-
-def buffered_generator(generator: Generator[T, None, None], buffer_size: int) -> Generator[list[T], None, None]:
- """Buffer the elements yielded by a generator. New elements replace the oldest elements in the buffer.
-
- Args:
- generator (Generator[T]): Generator object.
- buffer_size (int): Number of elements in the buffer.
-
- Returns:
- Generator[list[T], None, None]: Yields a buffer.
- """
- buffer = []
- for e in generator:
- buffer.append(e)
- if len(buffer) == buffer_size:
- break
- yield buffer
- for e in generator:
- buffer = buffer[1:] + [e]
- yield buffer
-
-
-def uniform_id_generator(
- ids: Iterable[T],
- repeat: bool = True,
- shuffle: bool = True,
-) -> Generator[T, None, None]:
- """Simple generator that yields ids in a uniform manner.
-
- Args:
- ids (pt.ArrayLike): Array of ids
- repeat (bool, optional): Whether to repeat generator. Defaults to True.
- shuffle (bool, optional): Whether to shuffle ids.. Defaults to True.
-
- Returns:
- Generator[T, None, None]: Generator
- Yields:
- T: Id
- """
- ids = np.copy(ids)
- while True:
- if shuffle:
- np.random.shuffle(ids)
- yield from ids
- if not repeat:
- break
- # END IF
- # END WHILE
-
-
-def random_id_generator(
- ids: Iterable[T],
- weights: list[int] | None = None,
-) -> Generator[T, None, None]:
- """Simple generator that yields ids in a random manner.
-
- Args:
- ids (pt.ArrayLike): Array of ids
- weights (list[int], optional): Weights for each id. Defaults to None.
-
- Returns:
- Generator[T, None, None]: Generator
-
- Yields:
- T: Id
- """
- while True:
- yield random.choice(ids)
- # END WHILE
-
-
-def transform_dataset_pipeline(
- ds: tf.data.Dataset,
- buffer_size: int | None = None,
- batch_size: int | None = None,
- prefetch_size: int | None = None,
-) -> tf.data.Dataset:
- """Transform dataset pipeline
-
- Args:
- ds (tf.data.Dataset): Dataset
- buffer_size (int | None, optional): Buffer size. Defaults to None.
- batch_size (int | None, optional): Batch size. Defaults to None.
- prefetch_size (int | None, optional): Prefetch size. Defaults to None.
-
- Returns:
- tf.data.Dataset: Transformed dataset
- """
- if buffer_size is not None:
- ds = ds.shuffle(
- buffer_size=buffer_size,
- reshuffle_each_iteration=True,
- )
- if batch_size is not None:
- ds = ds.batch(
- batch_size=batch_size,
- drop_remainder=False,
- )
- if prefetch_size is not None:
- ds = ds.prefetch(buffer_size=tf.data.AUTOTUNE)
- return ds
-
-
-def create_interleaved_dataset_from_generator(
- data_generator: Callable[[Generator[T, None, None]], Generator[K, None, None]],
- id_generator: Callable[[list[T]], Generator[T, None, None]],
- ids: list[T],
- spec: tuple[tf.TensorSpec, tf.TensorSpec],
- preprocess: Callable[[K], K] | None = None,
- num_workers: int = 4,
-) -> tf.data.Dataset:
- """Create TF dataset pipeline by interleaving multiple workers across ids
-
- The id_generator is used to generate ids for each worker.
- The data_generator is used to generate data for each id.
-
- Args:
- data_generator (Callable[[Generator[T, None, None]], Generator[K, None, None]]): Data generator
- id_generator (Callable[[list[T]], Generator[T, None, None]]): Id generator
- ids (list[T]): List of ids
- spec (tuple[tf.TensorSpec, tf.TensorSpec]): Tensor spec
- preprocess (Callable[[K], K] | None, optional): Preprocess function. Defaults to None.
- num_workers (int, optional): Number of workers. Defaults to 4.
-
- Returns:
- tf.data.Dataset: Dataset
- """
-
- def split_generator(split_ids: list[T]) -> tf.data.Dataset:
- """Split generator per worker"""
-
- def ds_gen():
- """Worker generator routine"""
- split_id_generator = id_generator(split_ids)
- return map(preprocess, data_generator(split_id_generator))
-
- return tf.data.Dataset.from_generator(
- ds_gen,
- output_signature=spec,
- )
-
- # END IF
-
- num_workers = min(num_workers, len(ids))
- split = len(ids) // num_workers
- logger.debug(f"Splitting {len(ids)} ids into {num_workers} workers with {split} ids each")
- ds_splits = [split_generator(ids[i * split : (i + 1) * split]) for i in range(num_workers)]
-
- # Create TF datasets (interleave workers)
- ds = tf.data.Dataset.from_tensor_slices(ds_splits)
-
- ds = ds.interleave(
- lambda x: x,
- cycle_length=num_workers,
- deterministic=False,
- num_parallel_calls=tf.data.AUTOTUNE,
- )
-
- return ds
-
-
-def _get_s3_client(config: Config | None = None) -> boto3.client:
- """Get S3 client
-
- Args:
- config (Config | None, optional): Boto3 config. Defaults to None.
-
- Returns:
- boto3.client: S3 client
- """
- session = boto3.Session()
- return session.client("s3", config=config)
-
-
-def download_s3_file(
- key: str,
- dst: Path,
- bucket: str,
- client: boto3.client = None,
- checksum: str = "size",
- config: Config | None = Config(signature_version=UNSIGNED),
-) -> bool:
- """Download a file from S3
-
- Args:
- key (str): Object key
- dst (Path): Destination path
- bucket (str): Bucket name
- client (boto3.client): S3 client
- checksum (str, optional): Checksum type. Defaults to "size".
- config (Config, optional): Boto3 config. Defaults to Config(signature_version=UNSIGNED).
-
- Returns:
- bool: True if file was downloaded, False if already exists
- """
-
- if client is None:
- client = _get_s3_client(config)
-
- if not dst.is_file():
- pass
- elif checksum == "size":
- obj = client.head_object(Bucket=bucket, Key=key)
- if dst.stat().st_size == obj["ContentLength"]:
- return False
- elif checksum == "md5":
- obj = client.head_object(Bucket=bucket, Key=key)
- etag = obj["ETag"]
- checksum_type = obj.get("ChecksumAlgorithm", ["md5"])[0]
- calculated_checksum = compute_checksum(dst, checksum)
- if etag == calculated_checksum and checksum_type.lower() == "md5":
- return False
- # END IF
-
- client.download_file(
- Bucket=bucket,
- Key=key,
- Filename=str(dst),
- )
-
- return True
-
-
-def download_s3_object(
- item: dict[str, str],
- dst: Path,
- bucket: str,
- client: boto3.client = None,
- checksum: str = "size",
- config: Config | None = Config(signature_version=UNSIGNED),
-) -> bool:
- """Download an object from S3
-
- Args:
- object (dict[str, str]): Object metadata
- dst (Path): Destination path
- bucket (str): Bucket name
- client (boto3.client): S3 client
- checksum (str, optional): Checksum type. Defaults to "size".
- config (Config, optional): Boto3 config. Defaults to Config(signature_version=UNSIGNED).
-
- Returns:
- bool: True if file was downloaded, False if already exists
- """
-
- # Is a directory, skip
- if item["Key"].endswith("/"):
- os.makedirs(dst, exist_ok=True)
- return False
-
- if not dst.is_file():
- pass
- elif checksum == "size":
- if dst.stat().st_size == item["Size"]:
- return False
- elif checksum == "md5":
- etag = item["ETag"]
- checksum_type = item.get("ChecksumAlgorithm", ["md5"])[0]
- calculated_checksum = compute_checksum(dst, checksum)
- if etag == calculated_checksum and checksum_type.lower() == "md5":
- return False
- # END IF
-
- if client is None:
- client = _get_s3_client()
-
- client.download_file(
- Bucket=bucket,
- Key=item["Key"],
- Filename=str(dst),
- )
-
- return True
-
-
-def download_s3_objects(
- bucket: str,
- prefix: str,
- dst: Path,
- checksum: str = "size",
- progress: bool = True,
- num_workers: int | None = None,
- config: Config | None = Config(signature_version=UNSIGNED),
-):
- """Download all objects in a S3 bucket with a given prefix
-
- Args:
- bucket (str): Bucket name
- prefix (str): Prefix to filter objects
- dst (Path): Destination directory
- checksum (str, optional): Checksum type. Defaults to "size".
- progress (bool, optional): Show progress bar. Defaults to True.
- num_workers (int | None, optional): Number of workers. Defaults to None.
- config (Config | None, optional): Boto3 config. Defaults to Config(signature_version=UNSIGNED).
-
- """
-
- client = _get_s3_client(config)
-
- # Fetch all objects in the bucket with the given prefix
- items = []
- fetching = True
- next_token = None
- while fetching:
- if next_token is None:
- response = client.list_objects_v2(Bucket=bucket, Prefix=prefix)
- else:
- response = client.list_objects_v2(Bucket=bucket, Prefix=prefix, ContinuationToken=next_token)
- items.extend(response["Contents"])
- next_token = response.get("NextContinuationToken", None)
- fetching = next_token is not None
- # END WHILE
-
- logger.debug(f"Found {len(items)} objects in {bucket}/{prefix}")
-
- os.makedirs(dst, exist_ok=True)
-
- func = functools.partial(download_s3_object, bucket=bucket, client=client, checksum=checksum)
-
- pbar = tqdm(total=len(items), unit="objects") if progress else None
-
- with ThreadPoolExecutor(max_workers=num_workers) as executor:
- futures = (
- executor.submit(
- func,
- item,
- dst / item["Key"],
- )
- for item in items
- )
- for future in as_completed(futures):
- err = future.exception()
- if err:
- logger.exception("Failed on file")
- if pbar:
- pbar.update(1)
- # END FOR
- # END WITH
diff --git a/heartkit/defines.py b/heartkit/defines.py
index daec6a7c..c5e06429 100644
--- a/heartkit/defines.py
+++ b/heartkit/defines.py
@@ -20,36 +20,15 @@ class QuantizationParams(BaseModel, extra="allow"):
fallback: bool = Field(False, description="Fallback to float32")
-class ModelArchitecture(BaseModel, extra="allow"):
- """Model architecture parameters"""
+class NamedParams(BaseModel, extra="allow"):
+ """Named parameters is used to store parameters for a specific model, preprocessing, or augmentation.
+ Typically name refers to class/method name and params is provided as kwargs.
+ """
name: str
params: dict[str, Any] = Field(default_factory=dict, description="Parameters")
-class PreprocessParams(BaseModel, extra="allow"):
- """Preprocessing parameters"""
-
- name: str
- params: dict[str, Any]
-
-
-class AugmentationParams(BaseModel, extra="allow"):
- """Augmentation parameters"""
-
- name: str
- params: dict[str, tuple[float | int, float | int]]
-
-
-class DatasetParams(BaseModel, extra="allow"):
- """Dataset parameters"""
-
- name: str
- path: Path = Field(default_factory=Path, description="Dataset path")
- params: dict[str, Any] = Field(default_factory=dict, description="Parameters")
- weight: float = Field(1, description="Dataset weight")
-
-
class HKMode(StrEnum):
"""HeartKit Mode"""
@@ -67,7 +46,7 @@ class HKDownloadParams(BaseModel, extra="allow"):
default_factory=lambda: Path(tempfile.gettempdir()),
description="Job output directory",
)
- datasets: list[DatasetParams] = Field(default_factory=list, description="Datasets")
+ datasets: list[NamedParams] = Field(default_factory=list, description="Datasets")
progress: bool = Field(True, description="Display progress bar")
force: bool = Field(False, description="Force download dataset- overriding existing files")
data_parallelism: int = Field(
@@ -76,199 +55,106 @@ class HKDownloadParams(BaseModel, extra="allow"):
)
-class HKTrainParams(BaseModel, extra="allow"):
- """Train command params"""
+class HKTaskParams(BaseModel, extra="allow"):
+ """Task command params"""
+ # Common arguments
name: str = Field("experiment", description="Experiment name")
project: str = Field("heartkit", description="Project name")
job_dir: Path = Field(
default_factory=lambda: Path(tempfile.gettempdir()),
description="Job output directory",
)
+
# Dataset arguments
- datasets: list[DatasetParams] = Field(default_factory=list, description="Datasets")
+ datasets: list[NamedParams] = Field(default_factory=list, description="Datasets")
+ dataset_weights: list[float] | None = Field(None, description="Dataset weights")
+ # Signal arguments
sampling_rate: int = Field(250, description="Target sampling rate (Hz)")
- frame_size: int = Field(1250, description="Frame size")
+ frame_size: int = Field(1250, description="Frame size in samples")
+
+ # Dataloader arguments
+ samples_per_patient: int | list[int] = Field(1000, description="# train samples per patient")
+ val_samples_per_patient: int | list[int] = Field(1000, description="# validation samples per patient")
+ test_samples_per_patient: int | list[int] = Field(1000, description="# test samples per patient")
+
+ # Preprocessing/Augmentation arguments
+ preprocesses: list[NamedParams] = Field(default_factory=list, description="Preprocesses")
+ augmentations: list[NamedParams] = Field(default_factory=list, description="Augmentations")
+
+ # Class arguments
num_classes: int = Field(1, description="# of classes")
class_map: dict[int, int] = Field(default_factory=lambda: {1: 1}, description="Class/label mapping")
class_names: list[str] | None = Field(default=None, description="Class names")
- samples_per_patient: int | list[int] = Field(1000, description="# train samples per patient")
- val_samples_per_patient: int | list[int] = Field(1000, description="# validation samples per patient")
+ # Split arguments
train_patients: float | None = Field(None, description="# or proportion of patients for training")
val_patients: float | None = Field(None, description="# or proportion of patients for validation")
+ test_patients: float | None = Field(None, description="# or proportion of patients for testing")
+
val_file: Path | None = Field(None, description="Path to load/store pickled validation file")
+ test_file: Path | None = Field(None, description="Path to load/store pickled test file")
val_size: int | None = Field(None, description="# samples for validation")
+ test_size: int = Field(10000, description="# samples for testing")
# Model arguments
resume: bool = Field(False, description="Resume training")
- architecture: ModelArchitecture | None = Field(default=None, description="Custom model architecture")
- model_file: Path | None = Field(None, description="Path to save model file (.keras)")
- threshold: float | None = Field(None, description="Model output threshold")
-
- weights_file: Path | None = Field(None, description="Path to a checkpoint weights to load")
+ architecture: NamedParams | None = Field(default=None, description="Custom model architecture")
+ model_file: Path | None = Field(None, description="Path to load/save model file (.keras)")
+ use_logits: bool = Field(True, description="Use logits output or softmax")
+ weights_file: Path | None = Field(None, description="Path to a checkpoint weights to load/save")
quantization: QuantizationParams = Field(default_factory=QuantizationParams, description="Quantization parameters")
+
# Training arguments
lr_rate: float = Field(1e-3, description="Learning rate")
lr_cycles: int = Field(3, description="Number of learning rate cycles")
lr_decay: float = Field(0.9, description="Learning rate decay")
- class_weights: Literal["balanced", "fixed"] = Field("fixed", description="Class weights")
label_smoothing: float = Field(0, description="Label smoothing")
batch_size: int = Field(32, description="Batch size")
- buffer_size: int = Field(100, description="Buffer size")
+ buffer_size: int = Field(100, description="Buffer cache size")
epochs: int = Field(50, description="Number of epochs")
steps_per_epoch: int = Field(10, description="Number of steps per epoch")
+ val_steps_per_epoch: int = Field(10, description="Number of validation steps")
val_metric: Literal["loss", "acc", "f1"] = Field("loss", description="Performance metric")
- # Preprocessing/Augmentation arguments
- preprocesses: list[PreprocessParams] = Field(default_factory=list, description="Preprocesses")
- augmentations: list[AugmentationParams] = Field(default_factory=list, description="Augmentations")
- # Extra arguments
- seed: int | None = Field(None, description="Random state seed")
- data_parallelism: int = Field(
- default_factory=lambda: os.cpu_count() or 1,
- description="# of data loaders running in parallel",
- )
- model_config = ConfigDict(protected_namespaces=())
- verbose: int = Field(1, ge=0, le=2, description="Verbosity level")
-
- def model_post_init(self, __context: Any) -> None:
- """Post init hook"""
-
- if self.val_file and len(self.val_file.parts) == 1:
- self.val_file = self.job_dir / self.val_file
-
- if self.model_file and len(self.model_file.parts) == 1:
- self.model_file = self.job_dir / self.model_file
+ class_weights: Literal["balanced", "fixed"] = Field("fixed", description="Class weights")
- if self.weights_file and len(self.weights_file.parts) == 1:
- self.weights_file = self.job_dir / self.weights_file
+ # Evaluation arguments
+ threshold: float | None = Field(None, description="Model output threshold")
+ val_metric_threshold: float | None = Field(0.98, description="Validation metric threshold")
+ # Export arguments
+ tflm_var_name: str = Field("g_model", description="TFLite Micro C variable name")
+ tflm_file: Path | None = Field(None, description="Path to copy TFLM header file (e.g. ./model_buffer.h)")
-class HKTestParams(BaseModel, extra="allow"):
- """Test command params"""
+ # Demo arguments
+ backend: str = Field("pc", description="Backend")
+ demo_size: int | None = Field(1000, description="# samples for demo")
+ display_report: bool = Field(True, description="Display report")
- name: str = Field("experiment", description="Experiment name")
- project: str = Field("heartkit", description="Project name")
- job_dir: Path = Field(
- default_factory=lambda: Path(tempfile.gettempdir()),
- description="Job output directory",
- )
- # Dataset arguments
- datasets: list[DatasetParams] = Field(default_factory=list, description="Datasets")
- sampling_rate: int = Field(250, description="Target sampling rate (Hz)")
- frame_size: int = Field(1250, description="Frame size")
- num_classes: int = Field(1, description="# of classes")
- class_map: dict[int, int] = Field(default_factory=lambda: {1: 1}, description="Class/label mapping")
- class_names: list[str] | None = Field(default=None, description="Class names")
- test_samples_per_patient: int | list[int] = Field(1000, description="# test samples per patient")
- test_patients: float | None = Field(None, description="# or proportion of patients for testing")
- test_size: int = Field(200_000, description="# samples for testing")
- test_file: Path | None = Field(None, description="Path to load/store pickled test file")
- preprocesses: list[PreprocessParams] = Field(default_factory=list, description="Preprocesses")
- augmentations: list[AugmentationParams] = Field(default_factory=list, description="Augmentations")
- # Model arguments
- model_file: Path | None = Field(None, description="Path to save model file (.keras)")
- threshold: float | None = Field(None, description="Model output threshold")
# Extra arguments
seed: int | None = Field(None, description="Random state seed")
data_parallelism: int = Field(
default_factory=lambda: os.cpu_count() or 1,
description="# of data loaders running in parallel",
)
- model_config = ConfigDict(protected_namespaces=())
verbose: int = Field(1, ge=0, le=2, description="Verbosity level")
-
- def model_post_init(self, __context: Any) -> None:
- """Post init hook"""
-
- if self.test_file and len(self.test_file.parts) == 1:
- self.test_file = self.job_dir / self.test_file
-
- if self.model_file and len(self.model_file.parts) == 1:
- self.model_file = self.job_dir / self.model_file
-
-
-class HKExportParams(BaseModel, extra="allow"):
- """Export command params"""
-
- name: str = Field("experiment", description="Experiment name")
- project: str = Field("heartkit", description="Project name")
- job_dir: Path = Field(
- default_factory=lambda: Path(tempfile.gettempdir()),
- description="Job output directory",
- )
- # Dataset arguments
- datasets: list[DatasetParams] = Field(default_factory=list, description="Datasets")
- sampling_rate: int = Field(250, description="Target sampling rate (Hz)")
- frame_size: int = Field(1250, description="Frame size")
- num_classes: int = Field(3, description="# of classes")
- class_map: dict[int, int] = Field(default_factory=lambda: {1: 1}, description="Class/label mapping")
- class_names: list[str] | None = Field(default=None, description="Class names")
- test_samples_per_patient: int | list[int] = Field(100, description="# test samples per patient")
- test_patients: float | None = Field(None, description="# or proportion of patients for testing")
- test_size: int = Field(100_000, description="# samples for testing")
- test_file: Path | None = Field(None, description="Path to load/store pickled test file")
- preprocesses: list[PreprocessParams] = Field(default_factory=list, description="Preprocesses")
- augmentations: list[AugmentationParams] = Field(default_factory=list, description="Augmentations")
- model_file: Path | None = Field(None, description="Path to save model file (.keras)")
- threshold: float | None = Field(None, description="Model output threshold")
- val_acc_threshold: float | None = Field(0.98, description="Validation accuracy threshold")
- use_logits: bool = Field(True, description="Use logits output or softmax")
- quantization: QuantizationParams = Field(default_factory=QuantizationParams, description="Quantization parameters")
- tflm_var_name: str = Field("g_model", description="TFLite Micro C variable name")
- tflm_file: Path | None = Field(None, description="Path to copy TFLM header file (e.g. ./model_buffer.h)")
- data_parallelism: int = Field(
- default_factory=lambda: os.cpu_count() or 1,
- description="# of data loaders running in parallel",
- )
model_config = ConfigDict(protected_namespaces=())
- verbose: int = Field(1, ge=0, le=2, description="Verbosity level")
def model_post_init(self, __context: Any) -> None:
"""Post init hook"""
+ if self.val_file and len(self.val_file.parts) == 1:
+ self.val_file = self.job_dir / self.val_file
+
if self.test_file and len(self.test_file.parts) == 1:
self.test_file = self.job_dir / self.test_file
if self.model_file and len(self.model_file.parts) == 1:
self.model_file = self.job_dir / self.model_file
+ if self.weights_file and len(self.weights_file.parts) == 1:
+ self.weights_file = self.job_dir / self.weights_file
+
if self.tflm_file and len(self.tflm_file.parts) == 1:
self.tflm_file = self.job_dir / self.tflm_file
-
-
-class HKDemoParams(BaseModel, extra="allow"):
- """HK demo command params"""
-
- name: str = Field("experiment", description="Experiment name")
- project: str = Field("heartkit", description="Project name")
- job_dir: Path = Field(
- default_factory=lambda: Path(tempfile.gettempdir()),
- description="Job output directory",
- )
- # Dataset arguments
- datasets: list[DatasetParams] = Field(default_factory=list, description="Datasets")
- sampling_rate: int = Field(250, description="Target sampling rate (Hz)")
- frame_size: int = Field(1250, description="Frame size")
- num_classes: int = Field(1, description="# of classes")
- class_map: dict[int, int] = Field(default_factory=lambda: {1: 1}, description="Class/label mapping")
- class_names: list[str] | None = Field(default=None, description="Class names")
- preprocesses: list[PreprocessParams] = Field(default_factory=list, description="Preprocesses")
- augmentations: list[AugmentationParams] = Field(default_factory=list, description="Augmentations")
- # Model arguments
- model_file: Path | None = Field(None, description="Path to save model file (.keras)")
- backend: str = Field("pc", description="Backend")
- # Demo arguments
- demo_size: int | None = Field(1000, description="# samples for demo")
- display_report: bool = Field(True, description="Display report")
- # Extra arguments
- seed: int | None = Field(None, description="Random state seed")
- model_config = ConfigDict(protected_namespaces=())
- verbose: int = Field(1, ge=0, le=2, description="Verbosity level")
-
- def model_post_init(self, __context: Any) -> None:
- """Post init hook"""
-
- if self.model_file and len(self.model_file.parts) == 1:
- self.model_file = self.job_dir / self.model_file
diff --git a/heartkit/metrics.py b/heartkit/metrics.py
deleted file mode 100644
index eac95f6f..00000000
--- a/heartkit/metrics.py
+++ /dev/null
@@ -1,145 +0,0 @@
-import warnings
-from typing import Literal
-
-import numpy as np
-import numpy.typing as npt
-from sklearn.metrics import f1_score, jaccard_score
-
-
-def compute_iou(
- y_true: npt.NDArray,
- y_pred: npt.NDArray,
- average: Literal["micro", "macro", "weighted"] = "micro",
-) -> float:
- """Compute IoU
-
- Args:
- y_true (npt.NDArray): Y true
- y_pred (npt.NDArray): Y predicted
-
- Returns:
- float: IoU
- """
- return jaccard_score(y_true.flatten(), y_pred.flatten(), average=average)
-
-
-def f1(
- y_true: npt.NDArray,
- y_prob: npt.NDArray,
- multiclass: bool = False,
- threshold: float = None,
-) -> npt.NDArray | float:
- """Compute F1 scores
-
- Args:
- y_true ( npt.NDArray): Y true
- y_prob ( npt.NDArray): 2D matrix with class probs
- multiclass (bool, optional): If multiclass. Defaults to False.
- threshold (float, optional): Decision threshold for multiclass. Defaults to None.
-
- Returns:
- npt.NDArray|float: F1 scores
- """
- if y_prob.ndim != 2:
- raise ValueError("y_prob must be a 2d matrix with class probabilities for each sample")
- if y_true.ndim == 1: # we assume that y_true is sparse (consequently, multiclass=False)
- if multiclass:
- raise ValueError("if y_true cannot be sparse and multiclass at the same time")
- depth = y_prob.shape[1]
- y_true = _one_hot(y_true, depth)
- if multiclass:
- if threshold is None:
- threshold = 0.5
- y_pred = y_prob >= threshold
- else:
- y_pred = y_prob >= np.max(y_prob, axis=1)[:, None]
- return f1_score(y_true, y_pred, average="macro")
-
-
-def f_max(
- y_true: npt.NDArray,
- y_prob: npt.NDArray,
- thresholds: float | list[float] | None = None,
-) -> tuple[float, float]:
- """Compute F max
- source: https://github.com/helme/ecg_ptbxl_benchmarking
-
- Args:
- y_true (npt.NDArray): Y True
- y_prob (npt.NDArray): Y probs
- thresholds (float|list[float]|None, optional): Thresholds. Defaults to None.
-
- Returns:
- tuple[float, float]: F1 and thresholds
- """
- if thresholds is None:
- thresholds = np.linspace(0, 1, 100)
- pr, rc = macro_precision_recall(y_true, y_prob, thresholds)
- f1s = (2 * pr * rc) / (pr + rc)
- i = np.nanargmax(f1s)
- return f1s[i], thresholds[i]
-
-
-def macro_precision_recall(
- y_true: npt.NDArray, y_prob: npt.NDArray, thresholds: npt.NDArray
-) -> tuple[np.float_, np.float_]:
- """Compute macro precision and recall
- source: https://github.com/helme/ecg_ptbxl_benchmarking
-
- Args:
- y_true (npt.NDArray): True y labels
- y_prob (npt.NDArray): Predicted y labels
- thresholds (npt.NDArray): Thresholds
-
- Returns:
- tuple[np.float_, np.float_]: Precision and recall
- """
- y_true = np.repeat(y_true[None, :, :], len(thresholds), axis=0)
- y_prob = np.repeat(y_prob[None, :, :], len(thresholds), axis=0)
- y_pred = y_prob >= thresholds[:, None, None]
-
- # compute true positives
- tp = np.sum(np.logical_and(y_true, y_pred), axis=2)
-
- # compute macro average precision handling all warnings
- with np.errstate(divide="ignore", invalid="ignore"):
- den = np.sum(y_pred, axis=2)
- precision = tp / den
- precision[den == 0] = np.nan
- with warnings.catch_warnings(): # for nan slices
- warnings.simplefilter("ignore", category=RuntimeWarning)
- av_precision = np.nanmean(precision, axis=1)
-
- # compute macro average recall
- recall = tp / np.sum(y_true, axis=2)
- av_recall = np.mean(recall, axis=1)
-
- return av_precision, av_recall
-
-
-def _one_hot(x: npt.NDArray, depth: int) -> npt.NDArray:
- """Generate one hot encoding
-
- Args:
- x (npt.NDArray): Categories
- depth (int): Depth
-
- Returns:
- npt.NDArray: One hot encoded
- """
- x_one_hot = np.zeros((x.size, depth))
- x_one_hot[np.arange(x.size), x] = 1
- return x_one_hot
-
-
-def multi_f1(y_true: npt.NDArray, y_prob: npt.NDArray) -> npt.NDArray | float:
- """Compute multi-class F1
-
- Args:
- y_true (npt.NDArray): True y labels
- y_prob (npt.NDArray): Predicted y labels
-
- Returns:
- npt.NDArray|float: F1 score
- """
- return f1(y_true, y_prob, multiclass=True, threshold=0.5)
diff --git a/heartkit/models/__init__.py b/heartkit/models/__init__.py
index 0838e508..e8ffb8cd 100644
--- a/heartkit/models/__init__.py
+++ b/heartkit/models/__init__.py
@@ -1,16 +1,30 @@
+"""ModelFactory is used to store and retrieve model generators.
+key (str): Model name slug (e.g. "unet")
+value (ModelFactoryItem): Model generator
+"""
+
from typing import Protocol
import keras
import neuralspot_edge as nse
-from ..utils import ItemFactory
-
class ModelFactoryItem(Protocol):
+ """ModelFactoryItem is a protocol for model factory items.
+
+ Args:
+ x (keras.KerasTensor): Input tensor
+ params (dict): Model parameters
+ num_classes (int): Number of classes
+
+ Returns:
+ keras.Model: Model
+ """
+
def __call__(self, x: keras.KerasTensor, params: dict, num_classes: int) -> keras.Model: ...
-ModelFactory = ItemFactory[ModelFactoryItem].shared("HKModelFactory")
+ModelFactory = nse.utils.ItemFactory[ModelFactoryItem].shared("HKModelFactory")
ModelFactory.register("unet", nse.models.unet.unet_from_object)
ModelFactory.register("unext", nse.models.unext.unext_from_object)
diff --git a/heartkit/rpc/__init__.py b/heartkit/rpc/__init__.py
index a3050642..f42d5012 100644
--- a/heartkit/rpc/__init__.py
+++ b/heartkit/rpc/__init__.py
@@ -1,11 +1,11 @@
+import neuralspot_edge as nse
+
from . import GenericDataOperations_EvbToPc as evb2pc
from . import GenericDataOperations_PcToEvb as pc2evb
from . import utils
-from .backends import DemoBackend, EvbBackend, PcBackend
-
-from ..utils import create_factory
+from .backends import HKInferenceBackend, EvbBackend, PcBackend
-BackendFactory = create_factory("HKDemoBackend", DemoBackend)
+BackendFactory = nse.utils.create_factory("HKDemoBackend", HKInferenceBackend)
BackendFactory.register("pc", PcBackend)
BackendFactory.register("evb", EvbBackend)
diff --git a/heartkit/rpc/backends.py b/heartkit/rpc/backends.py
index 95772014..07c51ed0 100644
--- a/heartkit/rpc/backends.py
+++ b/heartkit/rpc/backends.py
@@ -6,13 +6,12 @@
import numpy as np
import numpy.typing as npt
-from ..defines import HKDemoParams
-from ..utils import setup_logger
+from ..defines import HKTaskParams
from . import GenericDataOperations_PcToEvb as pc2evb
from . import erpc
from .utils import get_serial_transport
-logger = setup_logger(__name__)
+logger = nse.utils.setup_logger(__name__)
class RpcCommands(IntEnum):
@@ -25,10 +24,10 @@ class RpcCommands(IntEnum):
PERFORM_INFERENCE = 4
-class DemoBackend(abc.ABC):
+class HKInferenceBackend(abc.ABC):
"""Demo backend base class"""
- def __init__(self, params: HKDemoParams) -> None:
+ def __init__(self, params: HKTaskParams) -> None:
self.params = params
def open(self):
@@ -52,10 +51,10 @@ def get_outputs(self) -> npt.NDArray:
raise NotImplementedError
-class EvbBackend(DemoBackend):
+class EvbBackend(HKInferenceBackend):
"""Demo backend for EVB"""
- def __init__(self, params: HKDemoParams) -> None:
+ def __init__(self, params: HKTaskParams) -> None:
super().__init__(params=params)
self._interpreter = None
self._transport = None
@@ -148,10 +147,10 @@ def get_outputs(self) -> npt.NDArray:
return outputs
-class PcBackend(DemoBackend):
+class PcBackend(HKInferenceBackend):
"""Demo backend for PC"""
- def __init__(self, params: HKDemoParams) -> None:
+ def __init__(self, params: HKTaskParams) -> None:
super().__init__(params=params)
self._inputs = None
self._outputs = None
diff --git a/heartkit/tasks/__init__.py b/heartkit/tasks/__init__.py
index d2a1071c..ccc1c26b 100644
--- a/heartkit/tasks/__init__.py
+++ b/heartkit/tasks/__init__.py
@@ -1,3 +1,7 @@
+import neuralspot_edge as nse
+
+from . import beat, denoise, diagnostic, foundation, rhythm, segmentation
+
from .beat import BeatTask, HKBeat
from .denoise import DenoiseTask
from .diagnostic import DiagnosticTask, HKDiagnostic
@@ -6,10 +10,8 @@
from .segmentation import HKSegment, SegmentationTask
from .task import HKTask
from .translate import HKTranslate, TranslateTask
-from .utils import load_datasets
-from ..utils import create_factory
-TaskFactory = create_factory(factory="HKTaskFactory", type=HKTask)
+TaskFactory = nse.utils.create_factory(factory="HKTaskFactory", type=HKTask)
TaskFactory.register("rhythm", RhythmTask)
TaskFactory.register("beat", BeatTask)
diff --git a/heartkit/tasks/beat/__init__.py b/heartkit/tasks/beat/__init__.py
index 2069e8cf..0d826815 100644
--- a/heartkit/tasks/beat/__init__.py
+++ b/heartkit/tasks/beat/__init__.py
@@ -1,4 +1,4 @@
-from ...defines import HKDemoParams, HKExportParams, HKTestParams, HKTrainParams
+from ...defines import HKTaskParams
from ..task import HKTask
from .defines import HKBeat
from .demo import demo
@@ -11,17 +11,24 @@ class BeatTask(HKTask):
"""HeartKit Beat Task"""
@staticmethod
- def train(params: HKTrainParams):
+ def description() -> str:
+ return (
+ "This task is used to train, evaluate, and export beat models."
+ "Beat includes normal, pac, pvc, and other beats."
+ )
+
+ @staticmethod
+ def train(params: HKTaskParams):
train(params)
@staticmethod
- def evaluate(params: HKTestParams):
+ def evaluate(params: HKTaskParams):
evaluate(params)
@staticmethod
- def export(params: HKExportParams):
+ def export(params: HKTaskParams):
export(params)
@staticmethod
- def demo(params: HKDemoParams):
+ def demo(params: HKTaskParams):
demo(params)
diff --git a/heartkit/tasks/beat/dataloaders/__init__.py b/heartkit/tasks/beat/dataloaders/__init__.py
index d1c5e93c..a5ee9849 100644
--- a/heartkit/tasks/beat/dataloaders/__init__.py
+++ b/heartkit/tasks/beat/dataloaders/__init__.py
@@ -1 +1,10 @@
-from .icentia11k import icentia11k_data_generator, icentia11k_label_map
+import neuralspot_edge as nse
+
+from ....datasets import HKDataloader
+
+from .icentia11k import Icentia11kDataloader
+from .icentia_mini import IcentiaMiniDataloader
+
+BeatTaskFactory = nse.utils.create_factory(factory="HKBeatTaskFactory", type=HKDataloader)
+BeatTaskFactory.register("icentia11k", Icentia11kDataloader)
+BeatTaskFactory.register("icentia_mini", IcentiaMiniDataloader)
diff --git a/heartkit/tasks/beat/dataloaders/icentia11k.py b/heartkit/tasks/beat/dataloaders/icentia11k.py
index d36be08a..a349ca19 100644
--- a/heartkit/tasks/beat/dataloaders/icentia11k.py
+++ b/heartkit/tasks/beat/dataloaders/icentia11k.py
@@ -1,3 +1,4 @@
+import copy
import random
import functools
from typing import Generator, Iterable
@@ -5,9 +6,9 @@
import numpy as np
import numpy.typing as npt
import physiokit as pk
+import neuralspot_edge as nse
-from ....datasets.defines import PatientGenerator
-from ....datasets.icentia11k import IcentiaBeat, IcentiaDataset
+from ....datasets import HKDataloader, IcentiaDataset, IcentiaBeat
from ..defines import HKBeat
IcentiaBeatMap = {
@@ -37,73 +38,30 @@ def beat_filter_func(i: int, blabels: npt.NDArray, beat: IcentiaBeat):
# END MATCH
-# END DEF
-
-
-def icentia11k_label_map(
- label_map: dict[int, int] | None = None,
-) -> dict[int, int]:
- """Get label map
-
- Args:
- label_map (dict[int, int]|None): Label map
-
- Returns:
- dict[int, int]: Label map
- """
- return {k: label_map.get(v, -1) for (k, v) in IcentiaBeatMap.items()}
-
-
-def icentia11k_data_generator(
- patient_generator: PatientGenerator,
- ds: IcentiaDataset,
- frame_size: int,
- samples_per_patient: int | list[int] = 1,
- target_rate: int | None = None,
- label_map: dict[int, int] | None = None,
- label_type: str = "beat",
- filter: bool = False,
-) -> Generator[tuple[npt.NDArray, int], None, None]:
- """Generate frames w/ rhythm labels (e.g. afib) using patient generator.
-
- Args:
- patient_generator (PatientGenerator): Patient Generator
- ds: IcentiaDataset
- frame_size (int): Frame size
- samples_per_patient (int | list[int], optional): # samples per patient. Defaults to 1.
- target_rate (int|None, optional): Target rate. Defaults to None.
- label_map (dict[int, int] | None, optional): Label map. Defaults to None.
- label_type (str, optional): Label type. Defaults to "beat".
- filter (bool, optional): Filter beats. Defaults to False.
- Returns:
- Generator[tuple[npt.NDArray, int], None, None]: Sample generator
- """
- if target_rate is None:
- target_rate = ds.sampling_rate
- # END IF
-
- nlabel_threshold = 0.25
- blabel_padding = 20
-
- # Target labels and mapping
- tgt_labels = sorted(list(set((lbl for lbl in label_map.values() if lbl != -1))))
- label_key = ds.label_key(label_type)
-
- tgt_map = icentia11k_label_map(label_map=label_map)
- num_classes = len(tgt_labels)
-
- # If samples_per_patient is a list, then it must be the same length as nclasses
- if isinstance(samples_per_patient, Iterable):
- samples_per_tgt = samples_per_patient
- else:
- num_per_tgt = int(max(1, samples_per_patient / num_classes))
- samples_per_tgt = num_per_tgt * [num_classes]
-
- input_size = int(np.round((ds.sampling_rate / target_rate) * frame_size))
-
- # For each patient
- for pt in patient_generator:
- with ds.patient_data(pt) as segments:
+class Icentia11kDataloader(HKDataloader):
+ def __init__(self, ds: IcentiaDataset, **kwargs):
+ """Icentia11k Dataloader for training beat tasks"""
+ super().__init__(ds=ds, **kwargs)
+
+ # Update label map
+ if self.label_map:
+ self.label_map = {k: self.label_map[v] for (k, v) in IcentiaBeatMap.items() if v in self.label_map}
+ # END DEF
+ self.label_type = "beat"
+ # {PT: [label_idx: [segment, location]]}
+ self._pts_beat_map: dict[str, list[npt.NDArray]] = {}
+
+ def _create_beat_map(self, patient_id: int, enable_filter: bool = False):
+ """On initial access, create beat map for patient to improve speed"""
+ nlabel_threshold = 0.25
+ blabel_padding = 20
+
+ # Target labels and mapping
+ tgt_labels = sorted(set(self.label_map.values()))
+ label_key = self.ds.label_key(self.label_type)
+ num_classes = len(tgt_labels)
+
+ with self.ds.patient_data(patient_id) as segments:
# This maps segment index to segment key
seg_map: list[str] = list(segments.keys())
@@ -127,14 +85,14 @@ def icentia11k_data_generator(
# Capture all beat locations
for beat in IcentiaBeat:
# Skip if not in class map
- beat_class = tgt_map.get(beat, -1)
+ beat_class = self.label_map.get(beat, -1)
if beat_class < 0 or beat_class >= num_classes:
continue
# Get all beat type indices
beat_idxs = np.where(blabels[blabel_padding:-blabel_padding, 1] == beat.value)[0] + blabel_padding
- if filter: # Filter indices
+ if enable_filter: # Filter indices
fn = functools.partial(beat_filter_func, blabels=blabels, beat=beat)
beat_idxs = filter(fn, beat_idxs)
# END IF
@@ -142,11 +100,28 @@ def icentia11k_data_generator(
# END FOR
# END FOR
pt_beat_map = [np.array(b) for b in pt_beat_map]
+ self._pts_beat_map[patient_id] = pt_beat_map
+ # END WITH
+
+ def patient_data_generator(
+ self,
+ patient_id: int,
+ samples_per_patient: list[int],
+ ):
+ """Generate data for given patient id"""
+ input_size = int(np.ceil((self.ds.sampling_rate / self.sampling_rate) * self.frame_size))
+
+ with self.ds.patient_data(patient_id) as segments:
+ # This maps segment index to segment key
+ seg_map: list[str] = list(segments.keys())
+ if patient_id not in self._pts_beat_map:
+ self._create_beat_map(patient_id)
+ pt_beat_map = self._pts_beat_map[patient_id]
# Randomly select N samples of each target beat
pt_segs_beat_idxs: list[tuple[int, int, int]] = []
for tgt_beat_idx, tgt_beats in enumerate(pt_beat_map):
- tgt_count = min(samples_per_tgt[tgt_beat_idx], len(tgt_beats))
+ tgt_count = min(samples_per_patient[tgt_beat_idx], len(tgt_beats))
tgt_idxs = np.random.choice(np.arange(len(tgt_beats)), size=tgt_count, replace=False)
pt_segs_beat_idxs += [(tgt_beats[i][0], tgt_beats[i][1], tgt_beat_idx) for i in tgt_idxs]
# END FOR
@@ -154,16 +129,47 @@ def icentia11k_data_generator(
# Shuffle all
random.shuffle(pt_segs_beat_idxs)
- # Yield selected samples for patient
+ # Grab selected samples for patient
+ samples = []
for seg_idx, beat_idx, beat in pt_segs_beat_idxs:
frame_start = max(0, beat_idx - int(random.uniform(0.4722, 0.5278) * input_size))
frame_end = frame_start + input_size
data = segments[seg_map[seg_idx]]["data"]
x = np.nan_to_num(data[frame_start:frame_end]).astype(np.float32)
- if ds.sampling_rate != target_rate:
- x = pk.signal.resample_signal(x, ds.sampling_rate, target_rate, axis=0)
+ if self.ds.sampling_rate != self.sampling_rate:
+ x = pk.signal.resample_signal(x, self.ds.sampling_rate, self.sampling_rate, axis=0)
+ x = x[: self.frame_size] # truncate to frame size
y = beat
- yield x, y
+ samples.append((x, y))
# END FOR
# END WITH
- # END FOR
+
+ # Yield samples
+ for x, y in samples:
+ yield x, y
+ # END FOR
+
+ def data_generator(
+ self,
+ patient_ids: list[int],
+ samples_per_patient: int | list[int],
+ shuffle: bool = False,
+ ) -> Generator[tuple[npt.NDArray, npt.NDArray], None, None]:
+ """Generate data for given patient ids"""
+ # Target labels and mapping
+ tgt_labels = sorted(set(self.label_map.values()))
+ num_classes = len(tgt_labels)
+
+ # If samples_per_patient is a list, then it must be the same length as nclasses
+ if isinstance(samples_per_patient, Iterable):
+ samples_per_tgt = samples_per_patient
+ else:
+ num_per_tgt = int(max(1, samples_per_patient / num_classes))
+ samples_per_tgt = num_per_tgt * [num_classes]
+
+ pt_ids = copy.deepcopy(patient_ids)
+ for pt_id in nse.utils.uniform_id_generator(pt_ids, repeat=True, shuffle=shuffle):
+ for x, y in self.patient_data_generator(pt_id, samples_per_tgt):
+ yield x, y
+ # END FOR
+ # END FOR
diff --git a/heartkit/tasks/beat/dataloaders/icentia_mini.py b/heartkit/tasks/beat/dataloaders/icentia_mini.py
new file mode 100644
index 00000000..9e130da1
--- /dev/null
+++ b/heartkit/tasks/beat/dataloaders/icentia_mini.py
@@ -0,0 +1,88 @@
+import copy
+import random
+from typing import Generator, Iterable
+
+import numpy as np
+import numpy.typing as npt
+
+from ....datasets import HKDataloader, IcentiaMiniDataset, IcentiaMiniBeat
+from ..defines import HKBeat
+
+IcentiaBeatMap = {
+ IcentiaMiniBeat.normal: HKBeat.normal,
+ IcentiaMiniBeat.pac: HKBeat.pac,
+ IcentiaMiniBeat.aberrated: HKBeat.pac,
+ IcentiaMiniBeat.pvc: HKBeat.pvc,
+}
+
+
+class IcentiaMiniDataloader(HKDataloader):
+ def __init__(self, ds: IcentiaMiniDataset, **kwargs):
+ """IcentiaMini Dataloader for training beat tasks"""
+ super().__init__(ds=ds, **kwargs)
+ # Update label map
+ if self.label_map:
+ self.label_map = {k: self.label_map[v] for (k, v) in IcentiaBeatMap.items() if v in self.label_map}
+ self.label_type = "beat"
+
+ def data_generator(
+ self,
+ patient_ids: list[int],
+ samples_per_patient: int | list[int],
+ shuffle: bool = False,
+ ) -> Generator[tuple[npt.NDArray, npt.NDArray], None, None]:
+ """Generate data for given patient ids
+
+ Args:
+ patient_ids (list[int]): Patient IDs
+ samples_per_patient (int | list[int]): Samples per patient
+ shuffle (bool, optional): Shuffle data. Defaults to False.
+
+ Yields:
+ Generator[tuple[npt.NDArray, npt.NDArray], None, None]: Data generator
+ """
+ # Target labels and mapping
+ tgt_labels = sorted(list(set((lbl for lbl in self.label_map.values() if lbl != -1))))
+ label_key = self.ds.label_key(self.label_type)
+
+ num_classes = len(tgt_labels)
+
+ # If samples_per_patient is a list, then it must be the same length as nclasses
+ if isinstance(samples_per_patient, Iterable):
+ samples_per_tgt = samples_per_patient
+ else:
+ num_per_tgt = int(max(1, samples_per_patient / num_classes))
+ samples_per_tgt = num_per_tgt * [num_classes]
+
+ input_size = int(np.ceil((self.ds.sampling_rate / self.sampling_rate) * self.frame_size))
+ print(f"Input size: {input_size} {samples_per_tgt}")
+
+ pt_ids = copy.deepcopy(patient_ids)
+ while True:
+ for pt_id in pt_ids:
+ with self.ds.patient_data(pt_id) as pt:
+ # data = pt["data"][:] # has shape (N, M, 1)
+ # blabels is a mask with shape (N, M)
+ blabels = pt[label_key][:]
+
+ # Capture all beat locations
+ pt_beat_map = {}
+ for beat in IcentiaMiniBeat:
+ # Skip if not in class map
+ beat_class = self.label_map.get(beat, -1)
+ if beat_class < 0 or beat_class >= num_classes:
+ continue
+ # Get all beat type indices
+ rows, cols = np.where(blabels == beat.value)
+ # Zip rows and cols to form N, 2 array
+ pt_beat_map[beat_class] = np.array(list(zip(rows, cols)))
+ # END FOR
+ # END WITH
+ for samples in samples_per_patient:
+ for i in range(samples):
+ yield np.random.normal(size=(self.frame_size, 1)), np.random.randint(0, num_classes)
+ # END FOR
+
+ # END FOR
+ if shuffle:
+ random.shuffle(pt_ids)
diff --git a/heartkit/tasks/beat/datasets.py b/heartkit/tasks/beat/datasets.py
index d553d0e8..84c65da2 100644
--- a/heartkit/tasks/beat/datasets.py
+++ b/heartkit/tasks/beat/datasets.py
@@ -1,346 +1,197 @@
-import functools
-import logging
-from pathlib import Path
-
-import keras
import numpy as np
-import numpy.typing as npt
import tensorflow as tf
+import neuralspot_edge as nse
from ...datasets import (
HKDataset,
- augment_pipeline,
- preprocess_pipeline,
- uniform_id_generator,
-)
-from ...datasets.dataloader import test_dataloader, train_val_dataloader
-from ...defines import (
- AugmentationParams,
- HKExportParams,
- HKTestParams,
- HKTrainParams,
- PreprocessParams,
+ create_augmentation_pipeline,
)
-from ...utils import resolve_template_path
-from .dataloaders import icentia11k_data_generator, icentia11k_label_map
-
-logger = logging.getLogger(__name__)
-
-
-def preprocess(x: npt.NDArray, preprocesses: list[PreprocessParams], sample_rate: float) -> npt.NDArray:
- """Preprocess data pipeline
-
- Args:
- x (npt.NDArray): Input data
- preprocesses (list[PreprocessParams]): Preprocess parameters
- sample_rate (float): Sample rate
-
- Returns:
- tuple[npt.NDArray, npt.NDArray]: Preprocessed data
- """
- return preprocess_pipeline(x, preprocesses=preprocesses, sample_rate=sample_rate)
-
-
-def augment(x: npt.NDArray, augmentations: list[AugmentationParams], sample_rate: float) -> npt.NDArray:
- """Augment data pipeline
-
- Args:
- x (npt.NDArray): Input data
- augmentations (list[AugmentationParams]): Augmentation parameters
- sample_rate (float): Sample rate
-
- Returns:
- npt.NDArray: Augmented data
- """
-
- return augment_pipeline(x=x, augmentations=augmentations, sample_rate=sample_rate)
-
-
-def prepare(
- x_y: tuple[npt.NDArray, int],
- sample_rate: float,
- preprocesses: list[PreprocessParams] | None,
- augmentations: list[AugmentationParams] | None,
- spec: tuple[tf.TensorSpec, tf.TensorSpec],
- num_classes: int,
-) -> tuple[npt.NDArray, npt.NDArray]:
- """Prepare dataset
-
- Args:
- x_y (tuple[npt.NDArray, int]): Input data and label
- sample_rate (float): Sample rate
- preprocesses (list[PreprocessParams]|None): Preprocess parameters
- augmentations (list[AugmentationParams]|None): Augmentation parameters
- spec (tuple[tf.TensorSpec, tf.TensorSpec]): TensorSpec
- num_classes (int): Number of classes
-
- Returns:
- tuple[npt.NDArray, npt.NDArray]: Prepared data
- """
- x, y = x_y[0].copy(), x_y[1]
+from ...datasets.dataloader import HKDataloader
+from ...defines import HKTaskParams, NamedParams
- if augmentations:
- x = augment(x, augmentations, sample_rate)
- # END IF
+from .dataloaders import BeatTaskFactory
- if preprocesses:
- x = preprocess(x, preprocesses, sample_rate)
- # END IF
+logger = nse.utils.setup_logger(__name__)
- x = x.reshape(spec[0].shape)
- y = keras.ops.one_hot(y, num_classes)
- return x, y
-
-def get_ds_label_map(ds: HKDataset, label_map: dict[int, int] | None = None) -> dict[int, int]:
- """Get label map for dataset
-
- Args:
- ds (HKDataset): Dataset
- label_map (dict[int, int]|None): Label map
-
- Returns:
- dict[int, int]: Label map
- """
- match ds.name:
- case "icentia11k":
- return icentia11k_label_map(label_map=label_map)
- case _:
- raise ValueError(f"Dataset {ds.name} not supported")
- # END MATCH
-
-
-def get_data_generator(
- ds: HKDataset,
- frame_size: int,
- samples_per_patient: int,
- target_rate: int,
- label_map: dict[int, int] | None = None,
-):
- """Get task data generator for dataset
+def create_data_pipeline(
+ ds: tf.data.Dataset,
+ sampling_rate: int,
+ batch_size: int,
+ buffer_size: int | None = None,
+ augmentations: list[NamedParams] | None = None,
+ num_classes: int = 2,
+) -> tf.data.Dataset:
+ """Create a beat task data pipeline for given dataset.
Args:
- ds (HKDataset): Dataset
- frame_size (int): Frame size
- samples_per_patient (int): Samples per patient
- target_rate (int): Target rate
- label_map (dict[int, int] | None, optional): Label map. Defaults to None.
+ ds (tf.data.Dataset): Input dataset.
+ sampling_rate (int): Sampling rate of the dataset.
+ batch_size (int): Batch size.
+ buffer_size (int, optional): Buffer size for shuffling. Defaults to None.
+ augmentations (list[NamedParams], optional): List of augmentations. Defaults to None.
+ num_classes (int, optional): Number of classes. Defaults to 2.
Returns:
- callable: Data generator
+ tf.data.Dataset: Data pipeline.
"""
- match ds.name:
- case "icentia11k":
- data_generator = icentia11k_data_generator
- case _:
- raise ValueError(f"Dataset {ds.name} not supported")
- # END MATCH
- return functools.partial(
- data_generator,
- ds=ds,
- frame_size=frame_size,
- samples_per_patient=samples_per_patient,
- target_rate=target_rate,
- label_map=label_map,
+ if buffer_size:
+ ds = ds.shuffle(
+ buffer_size=buffer_size,
+ reshuffle_each_iteration=True,
+ )
+ if batch_size:
+ ds = ds.batch(
+ batch_size=batch_size,
+ drop_remainder=True,
+ num_parallel_calls=tf.data.AUTOTUNE,
+ )
+ augmenter = create_augmentation_pipeline(augmentations, sampling_rate=sampling_rate)
+ ds = (
+ ds.map(
+ lambda data, labels: {
+ "data": tf.cast(data, "float32"),
+ "labels": tf.one_hot(labels, num_classes),
+ },
+ num_parallel_calls=tf.data.AUTOTUNE,
+ )
+ .map(
+ augmenter,
+ num_parallel_calls=tf.data.AUTOTUNE,
+ )
+ .map(
+ lambda data: (data["data"], data["labels"]),
+ num_parallel_calls=tf.data.AUTOTUNE,
+ )
)
-
-def get_ds_label_type(ds: HKDataset) -> str:
- """Get label type for dataset
-
- Args:
- ds (HKDataset): Dataset
-
- Returns:
- str: Label type
- """
- return "beat" if ds.name == "icentia11k" else "scp"
-
-
-def resolve_ds_cache_path(fpath: Path | None, ds: HKDataset, task: str, frame_size: int, sample_rate: int):
- """Resolve dataset cache path
-
- Args:
- fpath (Path|None): File path
- ds (HKDataset): Dataset
- task (str): Task
- frame_size (int): Frame size
- sample_rate (int): Sampling rate
-
- Returns:
- Path|None: Resolved path
- """
- if not fpath:
- return None
- return resolve_template_path(
- fpath=fpath,
- dataset=ds.name,
- task=task,
- frame_size=frame_size,
- sampling_rate=sample_rate,
- )
+ return ds.prefetch(tf.data.AUTOTUNE)
def load_train_datasets(
datasets: list[HKDataset],
- params: HKTrainParams,
- ds_spec: tuple[tf.TensorSpec, tf.TensorSpec],
+ params: HKTaskParams,
) -> tuple[tf.data.Dataset, tf.data.Dataset]:
- """Load training and validation datasets
+ """Load training and validation tf.data.Datasets pipeline.
+
+ !!! note
+ if val_size or val_steps_per_epoch is given, then validation dataset will be
+ a fixed cached size. Otherwise, it will be a unbounded dataset generator. In
+ the latter case, a length will need to be passed to functions like `model.fit`.
Args:
- datasets (list[HKDataset]): Datasets
- params (HKTrainParams): Training parameters
- ds_spec (tuple[tf.TensorSpec, tf.TensorSpec]): TensorSpec
+ datasets (list[HKDataset]): List of datasets.
+ params (HKTaskParams): Training parameters.
Returns:
- tuple[tf.data.Dataset, tf.data.Dataset]: Train and validation datasets
+ tuple[tf.data.Dataset, tf.data.Dataset]: Training and validation datasets
"""
- id_generator = functools.partial(uniform_id_generator, repeat=True)
- train_prepare = functools.partial(
- prepare,
- sample_rate=params.sampling_rate,
- preprocesses=params.preprocesses,
- augmentations=params.augmentations,
- spec=ds_spec,
- num_classes=params.num_classes,
- )
-
- val_prepare = functools.partial(
- prepare,
- sample_rate=params.sampling_rate,
- preprocesses=params.preprocesses,
- augmentations=None,
- spec=ds_spec,
- num_classes=params.num_classes,
- )
-
train_datasets = []
val_datasets = []
for ds in datasets:
- val_file = resolve_ds_cache_path(
- params.val_file,
- ds=ds,
- task="beat",
- frame_size=params.frame_size,
- sample_rate=params.sampling_rate,
- )
- data_generator = get_data_generator(
+ dataloader: HKDataloader = BeatTaskFactory.get(ds.name)(
ds=ds,
frame_size=params.frame_size,
- samples_per_patient=params.samples_per_patient,
- target_rate=params.sampling_rate,
+ sampling_rate=params.sampling_rate,
label_map=params.class_map,
)
-
- train_ds, val_ds = train_val_dataloader(
- ds=ds,
- spec=ds_spec,
- data_generator=data_generator,
- id_generator=id_generator,
+ train_patients, val_patients = dataloader.split_train_val_patients(
train_patients=params.train_patients,
val_patients=params.val_patients,
- val_pt_samples=params.val_samples_per_patient,
- val_file=val_file,
- val_size=params.val_size,
- label_map=get_ds_label_map(ds, label_map=params.class_map),
- label_type=get_ds_label_type(ds),
- preprocess=train_prepare,
- val_preprocess=val_prepare,
- num_workers=params.data_parallelism,
+ )
+
+ train_ds = dataloader.create_dataloader(
+ patient_ids=train_patients, samples_per_patient=params.samples_per_patient, shuffle=True
+ )
+
+ val_ds = dataloader.create_dataloader(
+ patient_ids=val_patients, samples_per_patient=params.val_samples_per_patient, shuffle=False
)
train_datasets.append(train_ds)
val_datasets.append(val_ds)
# END FOR
- ds_weights = np.array([d.weight for d in params.datasets])
- ds_weights = ds_weights / ds_weights.sum()
+ ds_weights = None
+ if params.dataset_weights:
+ ds_weights = np.array(params.dataset_weights)
+ ds_weights = ds_weights / ds_weights.sum()
train_ds = tf.data.Dataset.sample_from_datasets(train_datasets, weights=ds_weights)
val_ds = tf.data.Dataset.sample_from_datasets(val_datasets, weights=ds_weights)
# Shuffle and batch datasets for training
- train_ds = (
- train_ds.shuffle(
- buffer_size=params.buffer_size,
- reshuffle_each_iteration=True,
- )
- .batch(
- batch_size=params.batch_size,
- drop_remainder=False,
- num_parallel_calls=tf.data.AUTOTUNE,
- )
- .prefetch(buffer_size=tf.data.AUTOTUNE)
+ train_ds = create_data_pipeline(
+ ds=train_ds,
+ sampling_rate=params.sampling_rate,
+ batch_size=params.batch_size,
+ buffer_size=params.buffer_size,
+ augmentations=params.augmentations + params.preprocesses,
+ num_classes=params.num_classes,
)
- val_ds = val_ds.batch(
+
+ val_ds = create_data_pipeline(
+ ds=val_ds,
+ sampling_rate=params.sampling_rate,
batch_size=params.batch_size,
- drop_remainder=True,
- num_parallel_calls=tf.data.AUTOTUNE,
+ augmentations=params.preprocesses,
+ num_classes=params.num_classes,
)
+
+ # If given fixed val size or steps, then capture and cache
+ val_steps_per_epoch = params.val_size // params.batch_size if params.val_size else params.val_steps_per_epoch
+ if val_steps_per_epoch:
+ logger.info(f"Validation steps per epoch: {val_steps_per_epoch}")
+ val_ds = val_ds.take(val_steps_per_epoch).cache()
+
return train_ds, val_ds
def load_test_dataset(
datasets: list[HKDataset],
- params: HKTestParams | HKExportParams,
- ds_spec: tuple[tf.TensorSpec, tf.TensorSpec],
+ params: HKTaskParams,
) -> tf.data.Dataset:
- """Load test dataset
+ """Load test tf.data.Dataset.
Args:
- datasets (list[HKDataset]): Datasets
- params (HKTestParams|HKExportParams): Test parameters
- ds_spec (tuple[tf.TensorSpec, tf.TensorSpec]): TensorSpec
+ datasets (list[HKDataset]): List of datasets.
+ params (HKTaskParams): Test parameters.
Returns:
tf.data.Dataset: Test dataset
"""
-
- id_generator = functools.partial(uniform_id_generator, repeat=True)
- test_prepare = functools.partial(
- prepare,
- sample_rate=params.sampling_rate,
- preprocesses=params.preprocesses,
- augmentations=None, # params.augmentations,
- spec=ds_spec,
- num_classes=params.num_classes,
- )
test_datasets = []
for ds in datasets:
- test_file = resolve_ds_cache_path(
- fpath=params.test_file,
- ds=ds,
- task="beat",
- frame_size=params.frame_size,
- sample_rate=params.sampling_rate,
- )
- data_generator = get_data_generator(
+ dataloader: HKDataloader = BeatTaskFactory.get(ds.name)(
ds=ds,
frame_size=params.frame_size,
- samples_per_patient=params.test_samples_per_patient,
- target_rate=params.sampling_rate,
+ sampling_rate=params.sampling_rate,
label_map=params.class_map,
)
-
- test_ds = test_dataloader(
- ds=ds,
- spec=ds_spec,
- data_generator=data_generator,
- id_generator=id_generator,
- test_patients=params.test_patients,
- test_file=test_file,
- label_map=get_ds_label_map(ds, label_map=params.class_map),
- label_type=get_ds_label_type(ds),
- preprocess=test_prepare,
- num_workers=params.data_parallelism,
+ test_patients = dataloader.test_patient_ids(params.test_patients)
+ test_ds = dataloader.create_dataloader(
+ patient_ids=test_patients,
+ samples_per_patient=params.test_samples_per_patient,
+ shuffle=False,
)
test_datasets.append(test_ds)
# END FOR
- ds_weights = np.array([d.weight for d in params.datasets])
- ds_weights = ds_weights / ds_weights.sum()
+ ds_weights = None
+ if params.dataset_weights:
+ ds_weights = np.array(params.dataset_weights)
+ ds_weights = ds_weights / ds_weights.sum()
test_ds = tf.data.Dataset.sample_from_datasets(test_datasets, weights=ds_weights)
+ test_ds = create_data_pipeline(
+ ds=test_ds,
+ sampling_rate=params.sampling_rate,
+ batch_size=params.batch_size,
+ augmentations=params.preprocesses,
+ num_classes=params.num_classes,
+ )
+
+ if params.test_size:
+ batch_size = getattr(params, "batch_size", 1)
+ test_ds = test_ds.take(params.test_size // batch_size).cache()
- # END WITH
return test_ds
diff --git a/heartkit/tasks/beat/demo.py b/heartkit/tasks/beat/demo.py
index 57061723..664ac262 100644
--- a/heartkit/tasks/beat/demo.py
+++ b/heartkit/tasks/beat/demo.py
@@ -1,23 +1,19 @@
import random
+import keras
import numpy as np
import numpy.typing as npt
import physiokit as pk
import plotly.graph_objects as go
-import tensorflow as tf
from plotly.subplots import make_subplots
-from rich.console import Console
from tqdm import tqdm
+import neuralspot_edge as nse
-from ...datasets import IcentiaDataset, PtbxlDataset, uniform_id_generator
-from ...defines import HKDemoParams
+from ...datasets import IcentiaDataset, PtbxlDataset, DatasetFactory, create_augmentation_pipeline
+from ...defines import HKTaskParams
from ...rpc import BackendFactory
-from ...utils import setup_logger
-from ..utils import load_datasets
-from .datasets import preprocess
-console = Console()
-logger = setup_logger(__name__)
+logger = nse.utils.setup_logger(__name__)
def get_ptbxl_patient_data(
@@ -38,7 +34,7 @@ def get_ptbxl_patient_data(
data = h5["data"][:]
blabels = h5[ds.label_key("beat")][:, 0] * 5 # Stored in 100Hz
# END WITH
- input_size = int(np.round((ds.sampling_rate / target_rate) * frame_size))
+ input_size = int(np.ceil((ds.sampling_rate / target_rate) * frame_size))
lead = random.choice(ds.leads)
start = np.random.randint(0, data.shape[1] - input_size)
x = data[lead, start : start + input_size].squeeze()
@@ -47,6 +43,7 @@ def get_ptbxl_patient_data(
if ds.sampling_rate != target_rate:
ratio = target_rate / ds.sampling_rate
x = pk.signal.resample_signal(x, ds.sampling_rate, target_rate, axis=0)
+ x = x[:frame_size] # truncate to frame size
y = (y * ratio).astype(np.int32)
# END IF
return x, y
@@ -70,7 +67,7 @@ def get_icentia11k_patient_data(
if target_rate is None:
target_rate = ds.sampling_rate
- input_size = int(np.round((ds.sampling_rate / target_rate) * frame_size))
+ input_size = int(np.ceil((ds.sampling_rate / target_rate) * frame_size))
label_key = ds.label_key("beat")
with ds.patient_data(patient_id) as segments:
@@ -87,6 +84,7 @@ def get_icentia11k_patient_data(
if ds.sampling_rate != target_rate:
ratio = target_rate / ds.sampling_rate
x = pk.signal.resample_signal(x, ds.sampling_rate, target_rate, axis=0)
+ x = x[:frame_size] # truncate to frame size
y = (y * ratio).astype(np.int32)
# END IF
@@ -94,11 +92,11 @@ def get_icentia11k_patient_data(
return x, y
-def demo(params: HKDemoParams):
+def demo(params: HKTaskParams):
"""Run demo on model.
Args:
- params (HKDemoParams): Demo parameters
+ params (HKTaskParams): Demo parameters
"""
bg_color = "rgba(38,42,50,1.0)"
@@ -112,21 +110,15 @@ def demo(params: HKDemoParams):
params.demo_size = params.demo_size or 20 * params.sampling_rate
# Load backend inference engine
- runner = BackendFactory.create(params.backend, params=params)
+ runner = BackendFactory.get(params.backend)(params=params)
# Load data
- # classes = sorted(list(set(params.class_map.values())))
class_names = params.class_names or [f"Class {i}" for i in range(params.num_classes)]
feat_shape = (params.frame_size, 1)
- # class_shape = (params.num_classes,)
- # ds_spec = (
- # tf.TensorSpec(shape=feat_shape, dtype=tf.float32),
- # tf.TensorSpec(shape=class_shape, dtype=tf.int32),
- # )
+ dsets = [DatasetFactory.get(ds.name)(**ds.params) for ds in params.datasets]
- dsets = load_datasets(datasets=params.datasets)
ds = random.choice(dsets)
if ds.name == "ptbxl":
pt_id = random.choice(ds.get_test_patient_ids())
@@ -147,7 +139,7 @@ def demo(params: HKDemoParams):
else:
# Need to manually locate peaks, compute
ds_gen = ds.signal_generator(
- patient_generator=uniform_id_generator(ds.get_test_patient_ids(), repeat=False),
+ patient_generator=nse.utils.uniform_id_generator(ds.get_test_patient_ids(), repeat=False),
frame_size=params.demo_size,
samples_per_patient=5,
target_rate=params.sampling_rate,
@@ -159,7 +151,11 @@ def demo(params: HKDemoParams):
# END IF
rri = pk.ecg.compute_rr_intervals(peaks)
- # mask = pk.ecg.filter_rr_intervals(rri, sample_rate=params.sampling_rate)
+
+ augmenter = create_augmentation_pipeline(
+ params.augmentations + params.preprocesses,
+ sampling_rate=params.sampling_rate,
+ )
# Run inference
runner.open()
@@ -174,16 +170,12 @@ def demo(params: HKDemoParams):
y_prob[i] = 0.0
continue
xx = x[start:stop]
- xx = preprocess(
- x[start:stop],
- sample_rate=params.sampling_rate,
- preprocesses=params.preprocesses,
- )
xx = xx.reshape(feat_shape)
+ xx = augmenter(xx)
runner.set_inputs(xx)
runner.perform_inference()
yy = runner.get_outputs()
- yy = tf.nn.softmax(yy).numpy()
+ yy = keras.ops.softmax(yy).numpy()
y_pred[i] = np.argmax(yy, axis=-1)
y_prob[i] = yy[y_pred[i]]
if y_prob[i] < params.threshold:
diff --git a/heartkit/tasks/beat/evaluate.py b/heartkit/tasks/beat/evaluate.py
index 92b41317..a4c8ec7b 100644
--- a/heartkit/tasks/beat/evaluate.py
+++ b/heartkit/tasks/beat/evaluate.py
@@ -1,51 +1,34 @@
-import logging
import os
import numpy as np
import keras
import neuralspot_edge as nse
-import tensorflow as tf
-from sklearn.metrics import f1_score
-from ...defines import HKTestParams
-from ...utils import set_random_seed, setup_logger
-from ..utils import load_datasets
+from ...defines import HKTaskParams
+from ...datasets import DatasetFactory
from .datasets import load_test_dataset
-logger = setup_logger(__name__)
-
-def evaluate(params: HKTestParams):
- """Evaluate model
+def evaluate(params: HKTaskParams):
+ """Evaluate beat task model on given parameters.
Args:
- params (HKTestParams): Evaluation parameters
+ params (HKTaskParams): Evaluation parameters
"""
- params.seed = set_random_seed(params.seed)
- logger.debug(f"Random seed {params.seed}")
-
os.makedirs(params.job_dir, exist_ok=True)
+ logger = nse.utils.setup_logger(__name__, level=params.verbose, file_path=params.job_dir / "test.log")
logger.debug(f"Creating working directory in {params.job_dir}")
- handler = logging.FileHandler(params.job_dir / "test.log", mode="w")
- handler.setLevel(logging.INFO)
- logger.addHandler(handler)
+ params.seed = nse.utils.set_random_seed(params.seed)
+ logger.debug(f"Random seed {params.seed}")
- # classes = sorted(list(set(params.class_map.values())))
class_names = params.class_names or [f"Class {i}" for i in range(params.num_classes)]
- feat_shape = (params.frame_size, 1)
- class_shape = (params.num_classes,)
+ datasets = [DatasetFactory.get(ds.name)(**ds.params) for ds in params.datasets]
- ds_spec = (
- tf.TensorSpec(shape=feat_shape, dtype="float32"),
- tf.TensorSpec(shape=class_shape, dtype="int32"),
- )
-
- datasets = load_datasets(datasets=params.datasets)
-
- test_ds = load_test_dataset(datasets=datasets, params=params, ds_spec=ds_spec)
- test_x, test_y = next(test_ds.batch(params.test_size).as_numpy_iterator())
+ test_ds = load_test_dataset(datasets=datasets, params=params)
+ test_x = np.concatenate([x for x, _ in test_ds.as_numpy_iterator()])
+ test_y = np.concatenate([y for _, y in test_ds.as_numpy_iterator()])
logger.debug("Loading model")
model = nse.models.load_model(params.model_file)
@@ -61,31 +44,28 @@ def evaluate(params: HKTestParams):
# Summarize results
logger.debug("Testing Results")
- test_acc = np.sum(y_pred == y_true) / len(y_true)
- test_f1 = f1_score(y_true, y_pred, average="weighted")
-
- logger.debug(f"[TEST SET] ACC={test_acc:.2%}, F1={test_f1:.2%}")
+ rst = model.evaluate(test_x, test_y, verbose=params.verbose, return_dict=True)
+ logger.info("[TEST SET] " + ", ".join([f"{k.upper()}={v:.2%}" for k, v in rst.items()]))
if params.num_classes == 2:
roc_path = params.job_dir / "roc_auc_test.png"
- nse.plotting.roc.roc_auc_plot(y_true, y_prob[:, 1], labels=class_names, save_path=roc_path)
+ nse.plotting.roc_auc_plot(y_true, y_prob[:, 1], labels=class_names, save_path=roc_path)
# END IF
# If threshold given, only count predictions above threshold
if params.threshold:
prev_numel = len(y_true)
- y_prob, y_pred, y_true = nse.metrics.threshold.threshold_predictions(y_prob, y_pred, y_true, params.threshold)
- drop_perc = 1 - len(y_true) / prev_numel
- test_acc = np.sum(y_pred == y_true) / len(y_true)
- test_f1 = f1_score(y_true, y_pred, average="weighted")
- logger.debug(f"[TEST SET] THRESH={params.threshold:0.2%}, DROP={drop_perc:.2%}")
- logger.debug(f"[TEST SET] ACC={test_acc:.2%}, F1={test_f1:.2%}")
+ indices = nse.metrics.threshold.get_predicted_threshold_indices(y_prob, y_pred, params.threshold)
+ test_x, test_y = test_x[indices], test_y[indices]
+ y_true, y_pred = y_true[indices], y_pred[indices]
+ rst = model.evaluate(test_x, test_y, verbose=params.verbose, return_dict=True)
+ logger.info(f"[TEST SET] THRESH={params.threshold:0.2%}, DROP={1 - len(indices) / prev_numel:.2%}")
+ logger.info("[TEST SET] " + ", ".join([f"{k.upper()}={v:.2%}" for k, v in rst.items()]))
# END IF
cm_path = params.job_dir / "confusion_matrix_test.png"
-
- nse.plotting.cm.confusion_matrix_plot(y_true, y_pred, labels=class_names, save_path=cm_path, normalize="true")
- nse.plotting.cm.px_plot_confusion_matrix(
+ nse.plotting.confusion_matrix_plot(y_true, y_pred, labels=class_names, save_path=cm_path, normalize="true")
+ nse.plotting.px_plot_confusion_matrix(
y_true,
y_pred,
labels=class_names,
diff --git a/heartkit/tasks/beat/export.py b/heartkit/tasks/beat/export.py
index b72faa9f..76021d5f 100644
--- a/heartkit/tasks/beat/export.py
+++ b/heartkit/tasks/beat/export.py
@@ -1,70 +1,47 @@
-import logging
import os
import shutil
import keras
-import neuralspot_edge as nse
import numpy as np
-import tensorflow as tf
-from sklearn.metrics import f1_score
+import neuralspot_edge as nse
-from ...defines import HKExportParams
-from ...utils import setup_logger
-from ..utils import load_datasets
+from ...defines import HKTaskParams
+from ...datasets import DatasetFactory
from .datasets import load_test_dataset
-logger = setup_logger(__name__)
-
-def export(params: HKExportParams):
- """Export model
+def export(params: HKTaskParams):
+ """Export beat task model with given parameters.
Args:
- params (HKExportParams): Deployment parameters
+ params (HKTaskParams): Deployment parameters
"""
-
os.makedirs(params.job_dir, exist_ok=True)
+ logger = nse.utils.setup_logger(__name__, level=params.verbose, file_path=params.job_dir / "export.log")
logger.debug(f"Creating working directory in {params.job_dir}")
- handler = logging.FileHandler(params.job_dir / "export.log", mode="w")
- handler.setLevel(logging.INFO)
- logger.addHandler(handler)
-
tfl_model_path = params.job_dir / "model.tflite"
tflm_model_path = params.job_dir / "model_buffer.h"
feat_shape = (params.frame_size, 1)
- class_shape = (params.num_classes,)
- ds_spec = (
- tf.TensorSpec(shape=feat_shape, dtype="float32"),
- tf.TensorSpec(shape=class_shape, dtype="int32"),
- )
+ datasets = [DatasetFactory.get(ds.name)(**ds.params) for ds in params.datasets]
- datasets = load_datasets(datasets=params.datasets)
+ test_ds = load_test_dataset(datasets=datasets, params=params)
+ test_x = np.concatenate([x for x, _ in test_ds.as_numpy_iterator()])
+ test_y = np.concatenate([y for _, y in test_ds.as_numpy_iterator()])
- test_ds = load_test_dataset(datasets=datasets, params=params, ds_spec=ds_spec)
- test_x, test_y = next(test_ds.batch(params.test_size).as_numpy_iterator())
-
- # Load model and set fixed batch size of 1
+ # Load model
logger.debug("Loading trained model")
model = nse.models.load_model(params.model_file)
+ # Add softmax layer if required
if not params.use_logits and not isinstance(model.layers[-1], keras.layers.Softmax):
- last_layer_name = model.layers[-1].name
-
- def call_function(layer, *args, **kwargs):
- out = layer(*args, **kwargs)
- if layer.name == last_layer_name:
- out = keras.layers.Softmax()(out)
- return out
-
- # END DEF
- model_clone = keras.models.clone_model(model, call_function=call_function)
- model_clone.set_weights(model.get_weights())
- model = model_clone
+ model = nse.models.append_layers(model, layers=[keras.layers.Softmax()], copy_weights=True)
# END IF
- inputs = keras.Input(shape=ds_spec[0].shape, batch_size=1, name="input", dtype=ds_spec[0].dtype.name)
+
+ # Fix batch size to 1
+ inputs = keras.Input(shape=feat_shape, batch_size=1, name="input", dtype="float32")
model(inputs)
flops = nse.metrics.flops.get_flops(model, batch_size=1, fpath=params.job_dir / "model_flops.log")
@@ -72,8 +49,9 @@ def call_function(layer, *args, **kwargs):
logger.debug(f"Model requires {flops/1e6:0.2f} MFLOPS")
logger.debug(f"Converting model to TFLite (quantization={params.quantization.mode})")
- tflite = nse.converters.tflite.TfLiteKerasConverter(model=model)
- tflite_content = tflite.convert(
+ converter = nse.converters.tflite.TfLiteKerasConverter(model=model)
+
+ tflite_content = converter.convert(
test_x=test_x,
quantization=params.quantization.format,
io_type=params.quantization.io_type,
@@ -82,41 +60,48 @@ def call_function(layer, *args, **kwargs):
)
if params.quantization.debug:
- quant_df = tflite.debug_quantization()
+ quant_df = converter.debug_quantization()
quant_df.to_csv(params.job_dir / "quant.csv")
# Save TFLite model
logger.debug(f"Saving TFLite model to {tfl_model_path}")
- tflite.export(tfl_model_path)
+ converter.export(tfl_model_path)
# Save TFLM model
logger.debug(f"Saving TFL micro model to {tflm_model_path}")
- tflite.export_header(tflm_model_path, name=params.tflm_var_name)
- tflite.cleanup()
+ converter.export_header(tflm_model_path, name=params.tflm_var_name)
+ converter.cleanup()
tflite = nse.interpreters.tflite.TfLiteKerasInterpreter(tflite_content)
tflite.compile()
# Verify TFLite results match TF results on example data
+ metrics = [
+ keras.metrics.CategoricalCrossentropy(name="loss", from_logits=params.use_logits),
+ keras.metrics.CategoricalAccuracy(name="acc"),
+ keras.metrics.F1Score(name="f1", average="weighted"),
+ ]
+
+ if params.val_metric not in [m.name for m in metrics]:
+ raise ValueError(f"Metric {params.val_metric} not supported")
+
logger.debug("Validating model results")
- y_true = np.argmax(test_y, axis=-1)
- y_pred_tf = np.argmax(model.predict(test_x), axis=-1)
- y_pred_tfl = np.argmax(tflite.predict(x=test_x), axis=-1)
+ y_true = test_y
+ y_pred_tf = model.predict(test_x)
+ y_pred_tfl = tflite.predict(x=test_x)
- tf_acc = np.sum(y_true == y_pred_tf) / y_true.size
- tf_f1 = f1_score(y_true, y_pred_tf, average="weighted")
- logger.debug(f"[TF SET] ACC={tf_acc:.2%}, F1={tf_f1:.2%}")
+ tf_rst = nse.metrics.compute_metrics(metrics, y_true, y_pred_tf)
+ tfl_rst = nse.metrics.compute_metrics(metrics, y_true, y_pred_tfl)
+ logger.info("[TF METRICS] " + " ".join([f"{k.upper()}={v:.2%}" for k, v in tf_rst.items()]))
+ logger.info("[TFL METRICS] " + " ".join([f"{k.upper()}={v:.2%}" for k, v in tfl_rst.items()]))
- tfl_acc = np.sum(y_true == y_pred_tfl) / y_true.size
- tfl_f1 = f1_score(y_true, y_pred_tfl, average="weighted")
- logger.debug(f"[TFL SET] ACC={tfl_acc:.2%}, F1={tfl_f1:.2%}")
+ metric_diff = abs(tf_rst[params.val_metric] - tfl_rst[params.val_metric])
# Check accuracy hit
- tfl_acc_drop = max(0, tf_acc - tfl_acc)
- if params.val_acc_threshold is not None and (1 - tfl_acc_drop) < params.val_acc_threshold:
- logger.warning(f"TFLite accuracy dropped by {tfl_acc_drop:0.2%}")
- elif params.val_acc_threshold:
- logger.debug(f"Validation passed ({tfl_acc_drop:0.2%})")
+ if params.val_metric_threshold is not None and metric_diff > params.val_metric_threshold:
+ logger.warning(f"TFLite accuracy dropped by {metric_diff:0.2%}")
+ elif params.val_metric_threshold:
+ logger.info(f"Validation passed ({metric_diff:0.2%})")
if params.tflm_file and tflm_model_path != params.tflm_file:
logger.debug(f"Copying TFLM header to {params.tflm_file}")
diff --git a/heartkit/tasks/beat/train.py b/heartkit/tasks/beat/train.py
index 6bf09f7b..89c708ad 100644
--- a/heartkit/tasks/beat/train.py
+++ b/heartkit/tasks/beat/train.py
@@ -1,122 +1,82 @@
-import logging
import os
import keras
import neuralspot_edge as nse
import numpy as np
import sklearn.utils
-import tensorflow as tf
import wandb
-from rich.console import Console
-from sklearn.metrics import f1_score
from wandb.keras import WandbMetricsLogger, WandbModelCheckpoint
-from ...defines import HKTrainParams
-from ...utils import env_flag, set_random_seed, setup_logger
-from ..utils import load_datasets
+from ...defines import HKTaskParams
+from ...datasets import DatasetFactory
+from ...models import ModelFactory
from .datasets import load_train_datasets
-from .utils import create_model
-console = Console()
-logger = setup_logger(__name__)
-
-def train(params: HKTrainParams):
- """Train model
+def train(params: HKTaskParams):
+ """Train beat task model with given parameters.
Args:
- params (HKTrainParams): Training parameters
+ params (HKTaskParams): Training parameters
"""
- params.seed = set_random_seed(params.seed)
- logger.debug(f"Random seed {params.seed}")
-
os.makedirs(params.job_dir, exist_ok=True)
+ logger = nse.utils.setup_logger(__name__, level=params.verbose, file_path=params.job_dir / "train.log")
logger.debug(f"Creating working directory in {params.job_dir}")
- handler = logging.FileHandler(params.job_dir / "train.log", mode="w")
- handler.setLevel(logging.INFO)
- logger.addHandler(handler)
+ params.seed = nse.utils.set_random_seed(params.seed)
+ logger.debug(f"Random seed {params.seed}")
with open(params.job_dir / "train_config.json", "w", encoding="utf-8") as fp:
fp.write(params.model_dump_json(indent=2))
- if env_flag("WANDB"):
- wandb.init(
- project=params.project,
- entity="ambiq",
- dir=params.job_dir,
- )
+ if nse.utils.env_flag("WANDB"):
+ wandb.init(project=params.project, entity="ambiq", dir=params.job_dir)
wandb.config.update(params.model_dump())
# END IF
- classes = sorted(list(set(params.class_map.values())))
+ classes = sorted(set(params.class_map.values()))
class_names = params.class_names or [f"Class {i}" for i in range(params.num_classes)]
feat_shape = (params.frame_size, 1)
- class_shape = (params.num_classes,)
-
- ds_spec = (
- tf.TensorSpec(shape=feat_shape, dtype="float32"),
- tf.TensorSpec(shape=class_shape, dtype="int32"),
- )
- datasets = load_datasets(datasets=params.datasets)
+ datasets = [DatasetFactory.get(ds.name)(**ds.params) for ds in params.datasets]
- train_ds, val_ds = load_train_datasets(
- datasets=datasets,
- params=params,
- ds_spec=ds_spec,
- )
+ train_ds, val_ds = load_train_datasets(datasets=datasets, params=params)
- test_labels = [label.numpy() for _, label in val_ds]
- y_true = np.argmax(np.concatenate(test_labels), axis=-1).flatten()
+ y_true = np.concatenate([y for _, y in val_ds.as_numpy_iterator()])
+ y_true = np.argmax(y_true, axis=-1)
class_weights = 0.25
if params.class_weights == "balanced":
class_weights = sklearn.utils.compute_class_weight("balanced", classes=np.array(classes), y=y_true)
class_weights = (class_weights + class_weights.mean()) / 2 # Smooth out
+ class_weights = class_weights.tolist()
# END IF
logger.debug(f"Class weights: {class_weights}")
- inputs = keras.Input(
- shape=ds_spec[0].shape,
- batch_size=None,
- name="input",
- dtype=ds_spec[0].dtype.name,
- )
+ inputs = keras.Input(shape=feat_shape, name="input", dtype="float32")
+
if params.resume and params.model_file:
logger.debug(f"Loading model from file {params.model_file}")
-
model = keras.models.load_model(params.model_file)
params.model_file = None
else:
logger.debug("Creating model from scratch")
- model = create_model(
- inputs,
+ model = ModelFactory.get(params.architecture.name)(
+ x=inputs,
+ params=params.architecture.params,
num_classes=params.num_classes,
- architecture=params.architecture,
)
# END IF
- if params.lr_cycles > 1:
- scheduler = keras.optimizers.schedules.CosineDecayRestarts(
- initial_learning_rate=params.lr_rate,
- first_decay_steps=int(0.1 * params.steps_per_epoch * params.epochs),
- t_mul=1.65 / (0.1 * params.lr_cycles * (params.lr_cycles - 1)),
- m_mul=0.4,
- )
- else:
- scheduler = keras.optimizers.schedules.CosineDecay(
- initial_learning_rate=params.lr_rate,
- decay_steps=params.steps_per_epoch * params.epochs,
- )
- # END IF
- optimizer = keras.optimizers.Adam(scheduler)
- loss = keras.losses.CategoricalFocalCrossentropy(from_logits=True, alpha=class_weights)
- metrics = [
- keras.metrics.CategoricalAccuracy(name="acc"),
- # tfa.MultiF1Score(name="f1", average="weighted"),
- ]
+ t_mul = 1
+ first_steps = (params.steps_per_epoch * params.epochs) / (np.power(params.lr_cycles, t_mul) - t_mul + 1)
+ scheduler = keras.optimizers.schedules.CosineDecayRestarts(
+ initial_learning_rate=params.lr_rate,
+ first_decay_steps=np.ceil(first_steps),
+ t_mul=t_mul,
+ m_mul=0.5,
+ )
if params.resume and params.weights_file:
logger.debug(f"Hydrating model weights from file {params.weights_file}")
@@ -125,14 +85,20 @@ def train(params: HKTrainParams):
if params.model_file is None:
params.model_file = params.job_dir / "model.keras"
+ optimizer = keras.optimizers.Adam(scheduler)
+ loss = keras.losses.CategoricalFocalCrossentropy(from_logits=True, alpha=class_weights)
+ metrics = [
+ keras.metrics.CategoricalAccuracy(name="acc"),
+ keras.metrics.F1Score(name="f1", average="weighted"),
+ ]
+
model.compile(optimizer=optimizer, loss=loss, metrics=metrics)
- model(inputs)
flops = nse.metrics.flops.get_flops(model, batch_size=1, fpath=params.job_dir / "model_flops.log")
model.summary(print_fn=logger.info)
logger.debug(f"Model requires {flops/1e6:0.2f} MFLOPS")
ModelCheckpoint = keras.callbacks.ModelCheckpoint
- if env_flag("WANDB"):
+ if nse.utils.env_flag("WANDB"):
ModelCheckpoint = WandbModelCheckpoint
model_callbacks = [
keras.callbacks.EarlyStopping(
@@ -140,31 +106,32 @@ def train(params: HKTrainParams):
patience=max(int(0.25 * params.epochs), 1),
mode="max" if params.val_metric == "f1" else "auto",
restore_best_weights=True,
+ verbose=params.verbose - 1,
),
ModelCheckpoint(
filepath=str(params.model_file),
monitor=f"val_{params.val_metric}",
save_best_only=True,
mode="max" if params.val_metric == "f1" else "auto",
- verbose=1,
+ verbose=params.verbose - 1,
),
keras.callbacks.CSVLogger(params.job_dir / "history.csv"),
]
- if env_flag("TENSORBOARD"):
+ if nse.utils.env_flag("TENSORBOARD"):
model_callbacks.append(
keras.callbacks.TensorBoard(
log_dir=params.job_dir,
write_steps_per_second=True,
)
)
- if env_flag("WANDB"):
+ if nse.utils.env_flag("WANDB"):
model_callbacks.append(WandbMetricsLogger())
try:
model.fit(
train_ds,
steps_per_epoch=params.steps_per_epoch,
- verbose=2,
+ verbose=params.verbose,
epochs=params.epochs,
validation_data=val_ds,
callbacks=model_callbacks,
@@ -175,18 +142,19 @@ def train(params: HKTrainParams):
logger.debug(f"Model saved to {params.model_file}")
# Get full validation results
- model = keras.models.load_model(params.model_file)
logger.debug("Performing full validation")
- y_pred = np.argmax(model.predict(val_ds), axis=-1).flatten()
+ y_pred = np.argmax(model.predict(val_ds, verbose=params.verbose), axis=-1)
cm_path = params.job_dir / "confusion_matrix.png"
- nse.plotting.cm.confusion_matrix_plot(y_true, y_pred, labels=class_names, save_path=cm_path, normalize="true")
- if env_flag("WANDB"):
- conf_mat = wandb.plot.confusion_matrix(preds=y_pred, y_true=y_true, class_names=class_names)
- wandb.log({"conf_mat": conf_mat})
- # END IF
+ nse.plotting.confusion_matrix_plot(y_true, y_pred, labels=class_names, save_path=cm_path, normalize="true")
+ nse.plotting.px_plot_confusion_matrix(
+ y_true,
+ y_pred,
+ labels=class_names,
+ save_path=cm_path.with_suffix(".html"),
+ normalize="true",
+ )
# Summarize results
- test_acc = np.sum(y_pred == y_true) / len(y_true)
- test_f1 = f1_score(y_true, y_pred, average="weighted")
- logger.debug(f"[VAL SET] ACC={test_acc:.2%}, F1={test_f1:.2%}")
+ rst = model.evaluate(val_ds, verbose=params.verbose, return_dict=True)
+ logger.info("[VAL SET] " + ", ".join([f"{k}={v:0.4f}" for k, v in rst.items()]))
diff --git a/heartkit/tasks/beat/utils.py b/heartkit/tasks/beat/utils.py
deleted file mode 100644
index 73133a45..00000000
--- a/heartkit/tasks/beat/utils.py
+++ /dev/null
@@ -1,96 +0,0 @@
-import keras
-from neuralspot_edge.models.efficientnet import (
- EfficientNetParams,
- EfficientNetV2,
- MBConvParams,
-)
-from rich.console import Console
-
-from ...defines import ModelArchitecture
-from ...models import ModelFactory
-
-console = Console()
-
-
-def create_model(inputs: keras.KerasTensor, num_classes: int, architecture: ModelArchitecture | None) -> keras.Model:
- """Generate model or use default
-
- Args:
- inputs (keras.KerasTensor): Model inputs
- num_classes (int): Number of classes
- architecture (ModelArchitecture|None): Model
-
- Returns:
- keras.Model: Model
- """
- if architecture:
- return ModelFactory.get(architecture.name)(
- x=inputs,
- params=architecture.params,
- num_classes=num_classes,
- )
-
- return default_model(inputs=inputs, num_classes=num_classes)
-
-
-def default_model(
- inputs: keras.KerasTensor,
- num_classes: int,
-) -> keras.Model:
- """Reference beat model
-
- Args:
- inputs (keras.KerasTensor): Model inputs
- num_classes (int): Number of classes
-
- Returns:
- keras.Model: Model
- """
- blocks = [
- MBConvParams(
- filters=32,
- depth=2,
- ex_ratio=1,
- kernel_size=(1, 3),
- strides=(1, 1),
- se_ratio=2,
- ),
- MBConvParams(
- filters=48,
- depth=2,
- ex_ratio=1,
- kernel_size=(1, 3),
- strides=(1, 2),
- se_ratio=2,
- ),
- MBConvParams(
- filters=64,
- depth=3,
- ex_ratio=1,
- kernel_size=(1, 3),
- strides=(1, 2),
- se_ratio=4,
- ),
- MBConvParams(
- filters=96,
- depth=3,
- ex_ratio=1,
- kernel_size=(1, 3),
- strides=(1, 2),
- se_ratio=4,
- ),
- ]
- return EfficientNetV2(
- inputs,
- params=EfficientNetParams(
- input_filters=24,
- input_strides=(1, 2),
- input_kernel_size=(1, 5),
- output_filters=0,
- blocks=blocks,
- include_top=True,
- dropout=0.0,
- drop_connect_rate=0.0,
- ),
- num_classes=num_classes,
- )
diff --git a/heartkit/tasks/denoise/__init__.py b/heartkit/tasks/denoise/__init__.py
index a6716dca..a1ea5d58 100644
--- a/heartkit/tasks/denoise/__init__.py
+++ b/heartkit/tasks/denoise/__init__.py
@@ -1,26 +1,27 @@
-from ...defines import HKDemoParams, HKExportParams, HKTestParams, HKTrainParams
+from ...defines import HKTaskParams
from ..task import HKTask
from .demo import demo
from .evaluate import evaluate
from .export import export
from .train import train
+from .dataloader import DenoiseDataloader
class DenoiseTask(HKTask):
"""HeartKit Denoise Task"""
@staticmethod
- def train(params: HKTrainParams):
+ def train(params: HKTaskParams):
train(params)
@staticmethod
- def evaluate(params: HKTestParams):
+ def evaluate(params: HKTaskParams):
evaluate(params)
@staticmethod
- def export(params: HKExportParams):
+ def export(params: HKTaskParams):
export(params)
@staticmethod
- def demo(params: HKDemoParams):
+ def demo(params: HKTaskParams):
demo(params)
diff --git a/heartkit/tasks/denoise/dataloader.py b/heartkit/tasks/denoise/dataloader.py
new file mode 100644
index 00000000..af567f4d
--- /dev/null
+++ b/heartkit/tasks/denoise/dataloader.py
@@ -0,0 +1,34 @@
+from typing import Generator
+
+import numpy as np
+import numpy.typing as npt
+import neuralspot_edge as nse
+
+
+from ...datasets import HKDataloader
+
+
+class DenoiseDataloader(HKDataloader):
+ def __init__(self, **kwargs):
+ """Generic Dataloader for denoising task."""
+ super().__init__(**kwargs)
+
+ def data_generator(
+ self,
+ patient_ids: list[int],
+ samples_per_patient: int | list[int],
+ shuffle: bool = False,
+ ) -> Generator[npt.NDArray, None, None]:
+ """Generate data for given patient ids.
+ Leveraging the signal_generator method from the dataset class to generate data.
+ """
+ gen = self.ds.signal_generator(
+ patient_generator=nse.utils.uniform_id_generator(patient_ids, repeat=True, shuffle=shuffle),
+ frame_size=self.frame_size,
+ samples_per_patient=samples_per_patient,
+ target_rate=self.sampling_rate,
+ )
+ for x in gen:
+ x = np.nan_to_num(x, neginf=0, posinf=0).astype(np.float32)
+ x = np.reshape(x, (-1, 1))
+ yield x
diff --git a/heartkit/tasks/denoise/dataloaders/__init__.py b/heartkit/tasks/denoise/dataloaders/__init__.py
deleted file mode 100644
index 3b0a30c0..00000000
--- a/heartkit/tasks/denoise/dataloaders/__init__.py
+++ /dev/null
@@ -1,4 +0,0 @@
-from .lsad import lsad_data_generator
-from .ptbxl import ptbxl_data_generator
-from .synthetic import synthetic_data_generator
-from .syntheticppg import synthetic_ppg_data_generator
diff --git a/heartkit/tasks/denoise/dataloaders/lsad.py b/heartkit/tasks/denoise/dataloaders/lsad.py
deleted file mode 100644
index 167ad9ee..00000000
--- a/heartkit/tasks/denoise/dataloaders/lsad.py
+++ /dev/null
@@ -1,40 +0,0 @@
-from typing import Generator, Iterable
-
-import numpy.typing as npt
-
-from ....datasets import LsadDataset, PatientGenerator
-
-
-def lsad_data_generator(
- patient_generator: PatientGenerator,
- ds: LsadDataset,
- frame_size: int,
- samples_per_patient: int | list[int] = 1,
- target_rate: int | None = None,
-) -> Generator[tuple[npt.NDArray, npt.NDArray], None, None]:
- """Generate frames using patient generator.
-
- Args:
- patient_generator (PatientGenerator): Patient Generator
- ds: LsadDataset
- frame_size (int): Frame size
- samples_per_patient (int | list[int], optional): # samples per patient. Defaults to 1.
- target_rate (int|None, optional): Target rate. Defaults to None.
-
- Returns:
- Generator[tuple[npt.NDArray, npt.NDArray], None, None]: Sample generator
-
- """
- if isinstance(samples_per_patient, Iterable):
- samples_per_patient = samples_per_patient[0]
-
- gen = ds.signal_generator(
- patient_generator=patient_generator,
- frame_size=frame_size,
- samples_per_patient=samples_per_patient,
- target_rate=target_rate,
- )
- for x in gen:
- y = x.copy()
- yield x, y
- # END FOR
diff --git a/heartkit/tasks/denoise/dataloaders/ptbxl.py b/heartkit/tasks/denoise/dataloaders/ptbxl.py
deleted file mode 100644
index 75fca173..00000000
--- a/heartkit/tasks/denoise/dataloaders/ptbxl.py
+++ /dev/null
@@ -1,40 +0,0 @@
-from typing import Generator, Iterable
-
-import numpy.typing as npt
-
-from ....datasets import PatientGenerator, PtbxlDataset
-
-
-def ptbxl_data_generator(
- patient_generator: PatientGenerator,
- ds: PtbxlDataset,
- frame_size: int,
- samples_per_patient: int | list[int] = 1,
- target_rate: int | None = None,
-) -> Generator[tuple[npt.NDArray, npt.NDArray], None, None]:
- """Generate frames using patient generator.
-
- Args:
- patient_generator (PatientGenerator): Patient Generator
- ds: PtbxlDataset
- frame_size (int): Frame size
- samples_per_patient (int | list[int], optional): # samples per patient. Defaults to 1.
- target_rate (int|None, optional): Target rate. Defaults to None.
-
- Returns:
- Generator[tuple[npt.NDArray, npt.NDArray], None, None]: Sample generator
-
- """
- if isinstance(samples_per_patient, Iterable):
- samples_per_patient = samples_per_patient[0]
-
- gen = ds.signal_generator(
- patient_generator=patient_generator,
- frame_size=frame_size,
- samples_per_patient=samples_per_patient,
- target_rate=target_rate,
- )
- for x in gen:
- y = x.copy()
- yield x, y
- # END FOR
diff --git a/heartkit/tasks/denoise/dataloaders/synthetic.py b/heartkit/tasks/denoise/dataloaders/synthetic.py
deleted file mode 100644
index 68098e6f..00000000
--- a/heartkit/tasks/denoise/dataloaders/synthetic.py
+++ /dev/null
@@ -1,40 +0,0 @@
-from typing import Generator, Iterable
-
-import numpy.typing as npt
-
-from ....datasets import PatientGenerator, SyntheticDataset
-
-
-def synthetic_data_generator(
- patient_generator: PatientGenerator,
- ds: SyntheticDataset,
- frame_size: int,
- samples_per_patient: int | list[int] = 1,
- target_rate: int | None = None,
-) -> Generator[tuple[npt.NDArray, npt.NDArray], None, None]:
- """Generate frames using patient generator.
-
- Args:
- patient_generator (PatientGenerator): Patient Generator
- ds: SyntheticDataset
- frame_size (int): Frame size
- samples_per_patient (int | list[int], optional): # samples per patient. Defaults to 1.
- target_rate (int|None, optional): Target rate. Defaults to None.
-
- Returns:
- Generator[tuple[npt.NDArray, npt.NDArray], None, None]: Sample generator
-
- """
- if isinstance(samples_per_patient, Iterable):
- samples_per_patient = samples_per_patient[0]
-
- gen = ds.signal_generator(
- patient_generator=patient_generator,
- frame_size=frame_size,
- samples_per_patient=samples_per_patient,
- target_rate=target_rate,
- )
- for x in gen:
- y = x.copy()
- yield x, y
- # END FOR
diff --git a/heartkit/tasks/denoise/dataloaders/syntheticppg.py b/heartkit/tasks/denoise/dataloaders/syntheticppg.py
deleted file mode 100644
index 07897c22..00000000
--- a/heartkit/tasks/denoise/dataloaders/syntheticppg.py
+++ /dev/null
@@ -1,40 +0,0 @@
-from typing import Generator, Iterable
-
-import numpy.typing as npt
-
-from ....datasets import PatientGenerator, SyntheticPpgDataset
-
-
-def synthetic_ppg_data_generator(
- patient_generator: PatientGenerator,
- ds: SyntheticPpgDataset,
- frame_size: int,
- samples_per_patient: int | list[int] = 1,
- target_rate: int | None = None,
-) -> Generator[tuple[npt.NDArray, npt.NDArray], None, None]:
- """Generate frames using patient generator.
-
- Args:
- patient_generator (PatientGenerator): Patient Generator
- ds: SyntheticPpgDataset
- frame_size (int): Frame size
- samples_per_patient (int | list[int], optional): # samples per patient. Defaults to 1.
- target_rate (int|None, optional): Target rate. Defaults to None.
-
- Returns:
- Generator[tuple[npt.NDArray, npt.NDArray], None, None]: Sample generator
-
- """
- if isinstance(samples_per_patient, Iterable):
- samples_per_patient = samples_per_patient[0]
-
- gen = ds.signal_generator(
- patient_generator=patient_generator,
- frame_size=frame_size,
- samples_per_patient=samples_per_patient,
- target_rate=target_rate,
- )
- for x in gen:
- y = x.copy()
- yield x, y
- # END FOR
diff --git a/heartkit/tasks/denoise/datasets.py b/heartkit/tasks/denoise/datasets.py
index 55d6597c..6f09c601 100644
--- a/heartkit/tasks/denoise/datasets.py
+++ b/heartkit/tasks/denoise/datasets.py
@@ -1,323 +1,172 @@
-import functools
-import logging
-from pathlib import Path
-
import numpy as np
-import numpy.typing as npt
import tensorflow as tf
+import neuralspot_edge as nse
-from ...datasets import (
- HKDataset,
- augment_pipeline,
- preprocess_pipeline,
- uniform_id_generator,
-)
-from ...datasets.dataloader import test_dataloader, train_val_dataloader
-from ...defines import (
- AugmentationParams,
- HKExportParams,
- HKTestParams,
- HKTrainParams,
- PreprocessParams,
-)
-from ...utils import resolve_template_path
-from .dataloaders import (
- lsad_data_generator,
- ptbxl_data_generator,
- synthetic_data_generator,
- synthetic_ppg_data_generator,
-)
-
-logger = logging.getLogger(__name__)
-
-
-def preprocess(x: npt.NDArray, preprocesses: list[PreprocessParams], sample_rate: float) -> npt.NDArray:
- """Preprocess data pipeline
-
- Args:
- x (npt.NDArray): Input data
- preprocesses (list[PreprocessParams]): Preprocess parameters
- sample_rate (float): Sample rate
-
- Returns:
- npt.NDArray: Preprocessed data
- """
- return preprocess_pipeline(x, preprocesses=preprocesses, sample_rate=sample_rate)
-
-
-def augment(x: npt.NDArray, augmentations: list[AugmentationParams], sample_rate: float) -> npt.NDArray:
- """Augment data pipeline
-
- Args:
- x (npt.NDArray): Input data
- augmentations (list[AugmentationParams]): Augmentation parameters
- sample_rate (float): Sample rate
-
- Returns:
- npt.NDArray: Augmented data
- """
-
- return augment_pipeline(x=x, augmentations=augmentations, sample_rate=sample_rate)
-
-
-def prepare(
- x_y: tuple[npt.NDArray, npt.NDArray],
- sample_rate: float,
- preprocesses: list[PreprocessParams],
- augmentations: list[AugmentationParams],
- spec: tuple[tf.TensorSpec, tf.TensorSpec],
- num_classes: int,
-) -> tuple[npt.NDArray, npt.NDArray]:
- """Prepare dataset
-
- Args:
- x_y (tuple[npt.NDArray, npt.NDArray]): Input data
- sample_rate (float): Sample rate
- preprocesses (list[PreprocessParams]|None): Preprocess parameters
- augmentations (list[AugmentationParams]|None): Augmentation parameters
- spec (tuple[tf.TensorSpec, tf.TensorSpec]): TensorSpec
- num_classes (int): Number of classes
-
- Returns:
- tuple[npt.NDArray, npt.NDArray]: Prepared data
- """
-
- x, y = x_y[0].copy(), x_y[1].copy()
-
- if augmentations:
- x = augment(x, augmentations, sample_rate)
- # END IF
-
- if preprocesses:
- x = preprocess(x, preprocesses, sample_rate)
- y = preprocess(y, preprocesses, sample_rate)
- # END IF
-
- x = x.reshape(spec[0].shape)
- y = y.reshape(spec[1].shape)
-
- return x, y
+from ...datasets import HKDataset, create_augmentation_pipeline
+from ...defines import HKTaskParams, NamedParams
+from .dataloader import DenoiseDataloader
-
-def get_ds_label_map(ds: HKDataset, label_map: dict[int, int] | None = None) -> dict[int, int]:
- """Get label map for dataset
-
- Args:
- ds (HKDataset): Dataset
- label_map (dict[int, int]|None): Label map
-
- Returns:
- dict[int, int]: Label map
- """
- return label_map
+logger = nse.utils.setup_logger(__name__)
-def get_data_generator(ds: HKDataset, frame_size: int, samples_per_patient: int, target_rate: int):
- """Get task data generator for dataset
-
- Args:
- ds (HKDataset): Dataset
- frame_size (int): Frame size
- samples_per_patient (int): Samples per patient
- target_rate (int): Target rate
-
- Returns:
- callable: Data generator
- """
- match ds.name:
- case "lsad":
- data_generator = lsad_data_generator
- case "ptbxl":
- data_generator = ptbxl_data_generator
- case "synthetic":
- data_generator = synthetic_data_generator
- case "syntheticppg":
- data_generator = synthetic_ppg_data_generator
- case _:
- raise ValueError(f"Dataset {ds.name} not supported")
- # END MATCH
- return functools.partial(
- data_generator,
- ds=ds,
- frame_size=frame_size,
- samples_per_patient=samples_per_patient,
- target_rate=target_rate,
- )
-
-
-def resolve_ds_cache_path(fpath: Path | None, ds: HKDataset, task: str, frame_size: int, sample_rate: int):
- """Resolve dataset cache path
+def create_data_pipeline(
+ ds: tf.data.Dataset,
+ sampling_rate: int,
+ batch_size: int,
+ buffer_size: int | None = None,
+ preprocesses: list[NamedParams] | None = None,
+ augmentations: list[NamedParams] | None = None,
+) -> tf.data.Dataset:
+ """ "Create 'tf.data.Dataset' pipeline.
Args:
- fpath (Path|None): File path
- ds (HKDataset): Dataset
- task (str): Task
- frame_size (int): Frame size
- sample_rate (int): Sampling rate
+ ds (tf.data.Dataset): Input dataset
+ sampling_rate (int): Sampling rate
+ batch_size (int): Batch size
+ buffer_size (int | None, optional): Buffer size. Defaults to None.
+ preprocesses (list[NamedParams] | None, optional): Preprocessing pipeline. Defaults to None.
+ augmentations (list[NamedParams] | None, optional): Augmentation pipeline. Defaults to None.
Returns:
- Path|None: Resolved path
+ tf.data.Dataset: Augmented dataset
"""
- if not fpath:
- return None
- return resolve_template_path(
- fpath=fpath,
- dataset=ds.name,
- task=task,
- frame_size=frame_size,
- sampling_rate=sample_rate,
- )
+ preprocessor = create_augmentation_pipeline(preprocesses, sampling_rate)
+ augmenter = create_augmentation_pipeline(augmentations, sampling_rate)
+ if buffer_size:
+ ds = ds.shuffle(
+ buffer_size=buffer_size,
+ reshuffle_each_iteration=True,
+ )
+ if batch_size:
+ ds = ds.batch(
+ batch_size=batch_size,
+ drop_remainder=True,
+ num_parallel_calls=tf.data.AUTOTUNE,
+ )
+ ds = ds.map(lambda x: preprocessor(x), num_parallel_calls=tf.data.AUTOTUNE)
+ ds = ds.map(lambda x: (augmenter(x), x), num_parallel_calls=tf.data.AUTOTUNE)
+ return ds.prefetch(tf.data.AUTOTUNE)
def load_train_datasets(
datasets: list[HKDataset],
- params: HKTrainParams,
- ds_spec: tuple[tf.TensorSpec, tf.TensorSpec],
+ params: HKTaskParams,
) -> tuple[tf.data.Dataset, tf.data.Dataset]:
- """Load training and validation datasets
+ """Load training and validation dataset pipelines
Args:
- datasets (list[HKDataset]): Datasets
- params (HKTrainParams): Training parameters
- ds_spec (tuple[tf.TensorSpec, tf.TensorSpec]): TensorSpec
-
- Returns:
- tuple[tf.data.Dataset, tf.data.Dataset]: Train and validation datasets
+ datasets (list[HKDataset]): List of datasets
+ params (HKTaskParams): Training parameters
"""
- id_generator = functools.partial(uniform_id_generator, repeat=True)
- train_prepare = functools.partial(
- prepare,
- sample_rate=params.sampling_rate,
- preprocesses=params.preprocesses,
- augmentations=params.augmentations,
- spec=ds_spec,
- num_classes=params.num_classes,
- )
-
train_datasets = []
val_datasets = []
for ds in datasets:
- val_file = resolve_ds_cache_path(
- params.val_file,
- ds=ds,
- task="denoise",
- frame_size=params.frame_size,
- sample_rate=params.sampling_rate,
- )
- data_generator = get_data_generator(
+ dataloader = DenoiseDataloader(
ds=ds,
frame_size=params.frame_size,
- samples_per_patient=params.samples_per_patient,
- target_rate=params.sampling_rate,
+ sampling_rate=params.sampling_rate,
+ label_map=params.class_map,
)
-
- train_ds, val_ds = train_val_dataloader(
- ds=ds,
- spec=ds_spec,
- data_generator=data_generator,
- id_generator=id_generator,
+ train_patients, val_patients = dataloader.split_train_val_patients(
train_patients=params.train_patients,
val_patients=params.val_patients,
- val_pt_samples=params.val_samples_per_patient,
- val_file=val_file,
- val_size=params.val_size,
- label_map=None,
- label_type=None,
- preprocess=train_prepare,
- num_workers=params.data_parallelism,
+ )
+
+ train_ds = dataloader.create_dataloader(
+ patient_ids=train_patients, samples_per_patient=params.samples_per_patient, shuffle=True
+ )
+
+ val_ds = dataloader.create_dataloader(
+ patient_ids=val_patients, samples_per_patient=params.val_samples_per_patient, shuffle=False
)
train_datasets.append(train_ds)
val_datasets.append(val_ds)
# END FOR
- ds_weights = np.array([d.weight for d in params.datasets])
- ds_weights = ds_weights / ds_weights.sum()
+ ds_weights = None
+ if params.dataset_weights:
+ ds_weights = np.array(params.dataset_weights)
+ ds_weights = ds_weights / ds_weights.sum()
train_ds = tf.data.Dataset.sample_from_datasets(train_datasets, weights=ds_weights)
val_ds = tf.data.Dataset.sample_from_datasets(val_datasets, weights=ds_weights)
# Shuffle and batch datasets for training
- train_ds = (
- train_ds.shuffle(
- buffer_size=params.buffer_size,
- reshuffle_each_iteration=True,
- )
- .batch(
- batch_size=params.batch_size,
- drop_remainder=False,
- num_parallel_calls=tf.data.AUTOTUNE,
- )
- .prefetch(buffer_size=tf.data.AUTOTUNE)
+ train_ds = create_data_pipeline(
+ ds=train_ds,
+ sampling_rate=params.sampling_rate,
+ batch_size=params.batch_size,
+ buffer_size=params.buffer_size,
+ preprocesses=params.preprocesses,
+ augmentations=params.augmentations,
)
- val_ds = val_ds.batch(
+
+ val_ds = create_data_pipeline(
+ ds=val_ds,
+ sampling_rate=params.sampling_rate,
batch_size=params.batch_size,
- drop_remainder=True,
- num_parallel_calls=tf.data.AUTOTUNE,
+ buffer_size=None,
+ preprocesses=params.preprocesses,
+ augmentations=params.augmentations,
)
+
+ # If given fixed val size or steps, then capture and cache
+ val_steps_per_epoch = params.val_size // params.batch_size if params.val_size else params.val_steps_per_epoch
+ if val_steps_per_epoch:
+ logger.info(f"Validation steps per epoch: {val_steps_per_epoch}")
+ val_ds = val_ds.take(val_steps_per_epoch).cache()
+
return train_ds, val_ds
def load_test_dataset(
datasets: list[HKDataset],
- params: HKTestParams | HKExportParams,
- ds_spec: tuple[tf.TensorSpec, tf.TensorSpec],
+ params: HKTaskParams,
) -> tf.data.Dataset:
- """Load test dataset
+ """Load test dataset pipeline
Args:
- datasets (list[HKDataset]): Datasets
- params (HKTestParams|HKExportParams): Test parameters
- ds_spec (tuple[tf.TensorSpec, tf.TensorSpec]): TensorSpec
+ datasets (list[HKDataset]): List of datasets
+ params (HKTaskParams): Test or export parameters
Returns:
- tf.data.Dataset: Test dataset
+ tf.data.Dataset: Test dataset pipeline
"""
-
- id_generator = functools.partial(uniform_id_generator, repeat=True)
- test_prepare = functools.partial(
- prepare,
- sample_rate=params.sampling_rate,
- preprocesses=params.preprocesses,
- augmentations=params.augmentations,
- spec=ds_spec,
- num_classes=params.num_classes,
- )
-
test_datasets = []
for ds in datasets:
- test_file = resolve_ds_cache_path(
- fpath=params.test_file,
+ dataloader = DenoiseDataloader(
ds=ds,
- task="denoise",
frame_size=params.frame_size,
- sample_rate=params.sampling_rate,
+ sampling_rate=params.sampling_rate,
+ label_map=params.class_map,
)
- data_generator = get_data_generator(
- ds=ds,
- frame_size=params.frame_size,
+ test_patients = dataloader.test_patient_ids(params.test_patients)
+ test_ds = dataloader.create_dataloader(
+ patient_ids=test_patients,
samples_per_patient=params.test_samples_per_patient,
- target_rate=params.sampling_rate,
- )
-
- test_ds = test_dataloader(
- ds=ds,
- spec=ds_spec,
- data_generator=data_generator,
- id_generator=id_generator,
- test_patients=params.test_patients,
- test_file=test_file,
- label_map=None,
- label_type=None,
- preprocess=test_prepare,
- num_workers=params.data_parallelism,
+ shuffle=False,
)
test_datasets.append(test_ds)
# END FOR
- ds_weights = np.array([d.weight for d in params.datasets])
- ds_weights = ds_weights / ds_weights.sum()
+ ds_weights = None
+ if params.dataset_weights:
+ ds_weights = np.array(params.dataset_weights)
+ ds_weights = ds_weights / ds_weights.sum()
test_ds = tf.data.Dataset.sample_from_datasets(test_datasets, weights=ds_weights)
- # END WITH
+ test_ds = create_data_pipeline(
+ ds=test_ds,
+ sampling_rate=params.sampling_rate,
+ batch_size=params.batch_size,
+ buffer_size=None,
+ preprocesses=params.preprocesses,
+ augmentations=params.augmentations,
+ )
+
+ if params.test_size:
+ batch_size = getattr(params, "batch_size", 1)
+ test_ds = test_ds.take(params.test_size // batch_size).cache()
+
return test_ds
diff --git a/heartkit/tasks/denoise/demo.py b/heartkit/tasks/denoise/demo.py
index f4bd6b75..13b203e7 100644
--- a/heartkit/tasks/denoise/demo.py
+++ b/heartkit/tasks/denoise/demo.py
@@ -2,25 +2,22 @@
import numpy as np
import plotly.graph_objects as go
-import tensorflow as tf
from plotly.subplots import make_subplots
from tqdm import tqdm
+import neuralspot_edge as nse
-from ...datasets.utils import uniform_id_generator
-from ...defines import HKDemoParams
+from ...defines import HKTaskParams
from ...rpc import BackendFactory
-from ...utils import setup_logger
-from ..utils import load_datasets
-from .datasets import prepare
+from ...datasets import DatasetFactory, create_augmentation_pipeline
-def demo(params: HKDemoParams):
+def demo(params: HKTaskParams):
"""Run segmentation demo.
Args:
- params (HKDemoParams): Demo parameters
+ params (HKTaskParams): Demo parameters
"""
- logger = setup_logger(__name__, level=params.verbose)
+ logger = nse.utils.setup_logger(__name__, level=params.verbose)
bg_color = "rgba(38,42,50,1.0)"
primary_color = "#11acd5"
@@ -32,36 +29,35 @@ def demo(params: HKDemoParams):
params.demo_size = params.demo_size or 10 * params.sampling_rate
# Load backend inference engine
- runner = BackendFactory.create(params.backend, params=params)
-
- feat_shape = (params.demo_size, 1)
- class_shape = (params.demo_size, 1)
-
- ds_spec = (
- tf.TensorSpec(shape=feat_shape, dtype="float32"),
- tf.TensorSpec(shape=class_shape, dtype="float32"),
- )
+ runner = BackendFactory.get(params.backend)(params=params)
# Load data
- dsets = load_datasets(datasets=params.datasets)
- ds = random.choice(dsets)
+ datasets = [DatasetFactory.get(ds.name)(cacheable=False, **ds.params) for ds in params.datasets]
+ ds = random.choice(datasets)
ds_gen = ds.signal_generator(
- patient_generator=uniform_id_generator(ds.get_test_patient_ids(), repeat=False),
+ patient_generator=nse.utils.uniform_id_generator(ds.get_test_patient_ids(), repeat=False),
frame_size=params.demo_size,
samples_per_patient=5,
target_rate=params.sampling_rate,
)
x = next(ds_gen)
+ x = np.nan_to_num(x, neginf=0, posinf=0).astype(np.float32)
+ x = np.reshape(x, (-1, 1))
+ y_act = x.copy()
- x, y_act = prepare(
- (x, x),
- sample_rate=params.sampling_rate,
- preprocesses=params.preprocesses,
- augmentations=params.augmentations,
- spec=ds_spec,
- num_classes=params.num_classes,
+ preprocessor = create_augmentation_pipeline(
+ params.preprocesses,
+ sampling_rate=params.sampling_rate,
)
+ augmenter = create_augmentation_pipeline(
+ params.augmentations,
+ sampling_rate=params.sampling_rate,
+ )
+
+ x = preprocessor(augmenter(x)).numpy()
+ y_act = preprocessor(y_act).numpy()
+
x = x.flatten()
y_act = y_act.flatten()
diff --git a/heartkit/tasks/denoise/evaluate.py b/heartkit/tasks/denoise/evaluate.py
index 860311d0..55ae9388 100644
--- a/heartkit/tasks/denoise/evaluate.py
+++ b/heartkit/tasks/denoise/evaluate.py
@@ -1,47 +1,28 @@
-import logging
import os
-import keras
-import numpy as np
-import tensorflow as tf
-
import neuralspot_edge as nse
-from ...defines import HKTestParams
-from ...utils import set_random_seed, setup_logger
-from ..utils import load_datasets
+
+from ...defines import HKTaskParams
+from ...datasets import DatasetFactory
from .datasets import load_test_dataset
-def evaluate(params: HKTestParams):
- """Evaluate model
+def evaluate(params: HKTaskParams):
+ """Evaluate model for denoise task with given parameters.
Args:
- params (HKTestParams): Evaluation parameters
+ params (HKTaskParams): Evaluation parameters
"""
- logger = setup_logger(__name__, level=params.verbose)
-
- params.seed = set_random_seed(params.seed)
- logger.debug(f"Random seed {params.seed}")
-
os.makedirs(params.job_dir, exist_ok=True)
+ logger = nse.utils.setup_logger(__name__, level=params.verbose, file_path=params.job_dir / "test.log")
logger.debug(f"Creating working directory in {params.job_dir}")
- handler = logging.FileHandler(params.job_dir / "test.log", mode="w")
- handler.setLevel(logging.INFO)
- logger.addHandler(handler)
-
- feat_shape = (params.frame_size, 1)
- class_shape = (params.frame_size, 1)
-
- ds_spec = (
- tf.TensorSpec(shape=feat_shape, dtype="float32"),
- tf.TensorSpec(shape=class_shape, dtype="float32"),
- )
+ params.seed = nse.utils.set_random_seed(params.seed)
+ logger.debug(f"Random seed {params.seed}")
- datasets = load_datasets(datasets=params.datasets)
+ datasets = [DatasetFactory.get(ds.name)(**ds.params) for ds in params.datasets]
- test_ds = load_test_dataset(datasets=datasets, params=params, ds_spec=ds_spec)
- test_x, test_y = next(test_ds.batch(params.test_size).as_numpy_iterator())
+ test_ds = load_test_dataset(datasets=datasets, params=params)
logger.debug("Loading model")
model = nse.models.load_model(params.model_file)
@@ -50,21 +31,7 @@ def evaluate(params: HKTestParams):
model.summary(print_fn=logger.debug)
logger.debug(f"Model requires {flops/1e6:0.2f} MFLOPS")
- logger.debug("Performing inference")
- y_true = test_y.squeeze()
- y_prob = model.predict(test_x)
- y_pred = y_prob.squeeze()
-
# Summarize results
- cossim = keras.metrics.CosineSimilarity()
- cossim.update_state(y_true, y_pred) # pylint: disable=E1102
- test_cossim = cossim.result().numpy() # pylint: disable=E1102
- logger.debug("Testing Results")
- mae = keras.metrics.MeanAbsoluteError()
- mae.update_state(y_true, y_pred) # pylint: disable=E1102
- test_mae = mae.result().numpy() # pylint: disable=E1102
- mse = keras.metrics.MeanSquaredError()
- mse.update_state(y_true, y_pred) # pylint: disable=E1102
- test_mse = mse.result().numpy() # pylint: disable=E1102
- np.sqrt(np.mean(np.square(y_true - y_pred)))
- logger.info(f"[TEST SET] MAE={test_mae:.2%}, MSE={test_mse:.2%}, COSSIM={test_cossim:.2%}")
+ logger.debug("Performing inference")
+ rst = model.evaluate(test_ds, verbose=params.verbose, return_dict=True)
+ logger.info("[TEST SET] " + ", ".join([f"{k.upper()}={v:.2%}" for k, v in rst.items()]))
diff --git a/heartkit/tasks/denoise/export.py b/heartkit/tasks/denoise/export.py
index 83d8eb73..611581b8 100644
--- a/heartkit/tasks/denoise/export.py
+++ b/heartkit/tasks/denoise/export.py
@@ -1,55 +1,41 @@
-import logging
import os
import shutil
import keras
import numpy as np
-import tensorflow as tf
-
import neuralspot_edge as nse
-from ...defines import HKExportParams
-from ...utils import setup_logger
-from ..utils import load_datasets
+
+from ...defines import HKTaskParams
+from ...datasets import DatasetFactory
from .datasets import load_test_dataset
-def export(params: HKExportParams):
+def export(params: HKTaskParams):
"""Export model
Args:
- params (HKExportParams): Deployment parameters
+ params (HKTaskParams): Deployment parameters
"""
- logger = setup_logger(__name__, level=params.verbose)
-
os.makedirs(params.job_dir, exist_ok=True)
+ logger = nse.utils.setup_logger(__name__, level=params.verbose, file_path=params.job_dir / "export.log")
logger.debug(f"Creating working directory in {params.job_dir}")
- handler = logging.FileHandler(params.job_dir / "export.log", mode="w")
- handler.setLevel(logging.INFO)
- logger.addHandler(handler)
-
tfl_model_path = params.job_dir / "model.tflite"
tflm_model_path = params.job_dir / "model_buffer.h"
feat_shape = (params.frame_size, 1)
- class_shape = (params.frame_size, 1)
- ds_spec = (
- tf.TensorSpec(shape=feat_shape, dtype="float32"),
- tf.TensorSpec(shape=class_shape, dtype="float32"),
- )
+ datasets = [DatasetFactory.get(ds.name)(**ds.params) for ds in params.datasets]
- datasets = load_datasets(datasets=params.datasets)
-
- test_ds = load_test_dataset(datasets=datasets, params=params, ds_spec=ds_spec)
- test_x, test_y = next(test_ds.batch(params.test_size).as_numpy_iterator())
+ test_ds = load_test_dataset(datasets=datasets, params=params)
+ test_x = np.concatenate([x for x, _ in test_ds.as_numpy_iterator()])
+ test_y = np.concatenate([y for _, y in test_ds.as_numpy_iterator()])
# Load model and set fixed batch size of 1
logger.debug("Loading trained model")
model = nse.models.load_model(params.model_file)
-
- inputs = keras.Input(shape=ds_spec[0].shape, batch_size=1, name="input", dtype=ds_spec[0].dtype)
- model(inputs) # Build model with fixed batch size of 1
+ inputs = keras.Input(shape=feat_shape, batch_size=1, name="input", dtype="float32")
+ model(inputs)
flops = nse.metrics.flops.get_flops(model, batch_size=1, fpath=params.job_dir / "model_flops.log")
model.summary(print_fn=logger.debug)
@@ -83,25 +69,33 @@ def export(params: HKExportParams):
tflite.compile()
# Verify TFLite results match TF results on example data
+ metrics = [
+ keras.metrics.MeanAbsoluteError(name="mae"),
+ keras.metrics.MeanSquaredError(name="mse"),
+ keras.metrics.RootMeanSquaredError(name="rmse"),
+ keras.metrics.CosineSimilarity(name="cosine"),
+ ]
+
+ if params.val_metric not in [m.name for m in metrics]:
+ raise ValueError(f"Metric {params.val_metric} not supported")
+
logger.info("Validating model results")
y_true = test_y
y_pred_tf = model.predict(test_x)
y_pred_tfl = tflite.predict(x=test_x)
- tf_mae = np.mean(np.abs(y_true - y_pred_tf))
- tf_rmse = np.sqrt(np.mean((y_true - y_pred_tf) ** 2))
- logger.info(f"[TF SET] MAE={tf_mae:.2%}, RMSE={tf_rmse:.2%}")
+ tf_rst = nse.metrics.compute_metrics(metrics, y_true, y_pred_tf)
+ tfl_rst = nse.metrics.compute_metrics(metrics, y_true, y_pred_tfl)
+ logger.info("[TF METRICS] " + " ".join([f"{k.upper()}={v:.2%}" for k, v in tf_rst.items()]))
+ logger.info("[TFL METRICS] " + " ".join([f"{k.upper()}={v:.2%}" for k, v in tfl_rst.items()]))
- tfl_mae = np.mean(np.abs(y_true - y_pred_tfl))
- tfl_rmse = np.sqrt(np.mean((y_true - y_pred_tfl) ** 2))
- logger.info(f"[TFL SET] MAE={tfl_mae:.2%}, RMSE={tfl_rmse:.2%}")
+ metric_diff = abs(tf_rst[params.val_metric] - tfl_rst[params.val_metric])
# Check accuracy hit
- tfl_acc_drop = max(0, tf_mae - tfl_mae)
- if params.val_acc_threshold is not None and (1 - tfl_acc_drop) < params.val_acc_threshold:
- logger.warning(f"TFLite accuracy dropped by {tfl_acc_drop:0.2%}")
- elif params.val_acc_threshold:
- logger.info(f"Validation passed ({tfl_acc_drop:0.2%})")
+ if params.val_metric_threshold is not None and metric_diff > params.val_metric_threshold:
+ logger.warning(f"TFLite accuracy dropped by {metric_diff:0.2%}")
+ elif params.val_metric_threshold:
+ logger.info(f"Validation passed ({metric_diff:0.2%})")
if params.tflm_file and tflm_model_path != params.tflm_file:
logger.debug(f"Copying TFLM header to {params.tflm_file}")
diff --git a/heartkit/tasks/denoise/metrics.py b/heartkit/tasks/denoise/metrics.py
deleted file mode 100644
index d33065f9..00000000
--- a/heartkit/tasks/denoise/metrics.py
+++ /dev/null
@@ -1,96 +0,0 @@
-import numpy as np
-import numpy.typing as npt
-
-
-def cossim(y_true: npt.NDArray, y_pred: npt.NDArray, axis: int = -1) -> npt.NDArray:
- """Cosine similarity averaged over the batch
-
- Args:
- y_true (npt.NDArray): True values
- y_pred (npt.NDArray): Predicted values
- axis (int, optional): Axis to sum. Defaults to 1.
-
- Returns:
- npt.NDArray: Cosine similarity
- """
- return np.mean(
- np.sum(y_true * y_pred, axis=axis) / (np.linalg.norm(y_true, axis=axis) * np.linalg.norm(y_pred, axis=axis))
- )
-
-
-def ssd(y_true: npt.NDArray, y_pred: npt.NDArray, axis: int = 1) -> npt.NDArray:
- """Sum of squared distance
-
- Args:
- y_true (npt.NDArray): True values
- y_pred (npt.NDArray): Predicted values
- axis (int, optional): Axis to sum. Defaults to 1.
-
- Returns:
- npt.NDArray: Sum of squared distance
- """
- return np.sum(np.square(y_true - y_pred), axis=axis)
-
-
-def mad(y_true: npt.NDArray, y_pred: npt.NDArray, axis: int = 1) -> npt.NDArray:
- """Absolute max difference
-
- Args:
- y_true (npt.NDArray): True values
- y_pred (npt.NDArray): Predicted values
- axis (int, optional): Axis to sum. Defaults to 1.
-
- Returns:
- npt.NDArray: Absolute max difference
- """
- return np.max(np.abs(y_true - y_pred), axis=axis)
-
-
-def prd(y_true: npt.NDArray, y_pred: npt.NDArray, axis: int = 1) -> npt.NDArray:
- """Percentage root mean square difference
-
- Args:
- y_true (npt.NDArray): True values
- y_pred (npt.NDArray): Predicted values
- axis (int, optional): Axis to sum. Defaults to 1.
-
- Returns:
- npt.NDArray: Percentage root mean square difference
- """
- N = np.sum(np.square(y_pred - y_true), axis=axis)
- D = np.sum(np.square(y_pred - np.mean(y_true)), axis=axis)
- PRD = np.sqrt(N / D) * 100
-
- return PRD
-
-
-def snr(y1: npt.NDArray, y2: npt.NDArray) -> npt.NDArray:
- """Compute signal to noise ratio
-
- Args:
- y1 (npt.NDArray): True values
- y2 (npt.NDArray): Predicted values
-
- Returns:
- npt.NDArray: Signal to noise ratio
- """
- N = np.sum(np.square(y1), axis=1)
- D = np.sum(np.square(y2 - y1), axis=1)
-
- SNR = 10 * np.log10(N / D)
-
- return SNR
-
-
-def snr_improvement(y_in: npt.NDArray, y_out: npt.NDArray, y_clean: npt.NDArray) -> npt.NDArray:
- """Compute signal to noise ratio improvement
-
- Args:
- y_in (npt.NDArray): Input signal
- y_out (npt.NDArray): Output signal
- y_clean (npt.NDArray): Clean signal
-
- Returns:
- npt.NDArray: Signal to noise ratio improvement
- """
- return snr(y_clean, y_out) - snr(y_clean, y_in)
diff --git a/heartkit/tasks/denoise/train.py b/heartkit/tasks/denoise/train.py
index 147d224a..bcc781bc 100644
--- a/heartkit/tasks/denoise/train.py
+++ b/heartkit/tasks/denoise/train.py
@@ -1,45 +1,35 @@
-import logging
import os
+import numpy as np
import keras
-import tensorflow as tf
import wandb
from wandb.keras import WandbMetricsLogger, WandbModelCheckpoint
import neuralspot_edge as nse
-from ...defines import HKTrainParams
-from ...utils import env_flag, set_random_seed, setup_logger
-from ..utils import load_datasets
+from ...defines import HKTaskParams
+from ...datasets import DatasetFactory
from .datasets import load_train_datasets
-from .utils import create_model
+from ...models import ModelFactory
-def train(params: HKTrainParams):
- """Train model
+def train(params: HKTaskParams):
+ """Train model for denoise task with given parameters.
Args:
- params (HKTrainParams): Training parameters
+ params (HKTaskParams): Training parameters
"""
- logger = setup_logger(__name__, level=params.verbose)
-
- params.seed = set_random_seed(params.seed)
- logger.debug(f"Random seed {params.seed}")
-
os.makedirs(params.job_dir, exist_ok=True)
+ logger = nse.utils.setup_logger(__name__, level=params.verbose, file_path=params.job_dir / "train.log")
logger.debug(f"Creating working directory in {params.job_dir}")
- handler = logging.FileHandler(params.job_dir / "train.log", mode="w")
- handler.setLevel(logger.level)
- logger.addHandler(handler)
+
+ params.seed = nse.utils.set_random_seed(params.seed)
+ logger.debug(f"Random seed {params.seed}")
with open(params.job_dir / "train_config.json", "w", encoding="utf-8") as fp:
fp.write(params.model_dump_json(indent=2))
- if env_flag("WANDB"):
- wandb.init(
- project=params.project,
- entity="ambiq",
- dir=params.job_dir,
- )
+ if nse.utils.env_flag("WANDB"):
+ wandb.init(project=params.project, entity="ambiq", dir=params.job_dir)
wandb.config.update(params.model_dump())
# END IF
@@ -48,27 +38,12 @@ def train(params: HKTrainParams):
params.class_names = ["CLEAN"]
feat_shape = (params.frame_size, 1)
- class_shape = (params.frame_size, 1)
-
- ds_spec = (
- tf.TensorSpec(shape=feat_shape, dtype="float32"),
- tf.TensorSpec(shape=class_shape, dtype="float32"),
- )
- datasets = load_datasets(datasets=params.datasets)
+ datasets = [DatasetFactory.get(ds.name)(**ds.params) for ds in params.datasets]
- train_ds, val_ds = load_train_datasets(
- datasets=datasets,
- params=params,
- ds_spec=ds_spec,
- )
+ train_ds, val_ds = load_train_datasets(datasets=datasets, params=params)
- inputs = keras.Input(
- shape=ds_spec[0].shape,
- batch_size=None,
- name="input",
- dtype=ds_spec[0].dtype.name,
- )
+ inputs = keras.Input(shape=feat_shape, name="input", dtype="float32")
# Load existing model
if params.resume and params.model_file:
@@ -77,57 +52,54 @@ def train(params: HKTrainParams):
params.model_file = None
else:
logger.debug("Creating model from scratch")
- model = create_model(
- inputs,
+ model = ModelFactory.get(params.architecture.name)(
+ x=inputs,
+ params=params.architecture.params,
num_classes=params.num_classes,
- architecture=params.architecture,
)
# END IF
- if params.lr_cycles > 1:
- scheduler = keras.optimizers.schedules.CosineDecayRestarts(
- initial_learning_rate=params.lr_rate,
- first_decay_steps=int(0.1 * params.steps_per_epoch * params.epochs),
- t_mul=1.65 / (0.1 * params.lr_cycles * (params.lr_cycles - 1)),
- m_mul=0.4,
- )
- else:
- scheduler = keras.optimizers.schedules.CosineDecay(
- initial_learning_rate=params.lr_rate,
- decay_steps=params.steps_per_epoch * params.epochs,
- )
- # END IF
+ t_mul = 1
+ first_steps = (params.steps_per_epoch * params.epochs) / (np.power(params.lr_cycles, t_mul) - t_mul + 1)
+ scheduler = keras.optimizers.schedules.CosineDecayRestarts(
+ initial_learning_rate=params.lr_rate,
+ first_decay_steps=np.ceil(first_steps),
+ t_mul=t_mul,
+ m_mul=0.5,
+ )
+
+ if params.resume and params.weights_file and params.weights_file.exists():
+ logger.debug(f"Hydrating model weights from file {params.weights_file}")
+ model.load_weights(params.weights_file)
+
+ if params.model_file is None:
+ params.model_file = params.job_dir / "model.keras"
optimizer = keras.optimizers.Adam(scheduler)
loss = keras.losses.MeanSquaredError()
+ # loss = keras.losses.Huber()
metrics = [
keras.metrics.MeanAbsoluteError(name="mae"),
keras.metrics.MeanSquaredError(name="mse"),
- keras.metrics.CosineSimilarity(name="cosine"),
+ keras.metrics.CosineSimilarity(name="cos"),
+ nse.metrics.Snr(name="snr"),
]
- if params.resume and params.weights_file:
- logger.debug(f"Hydrating model weights from file {params.weights_file}")
- model.load_weights(params.weights_file)
-
- if params.model_file is None:
- params.model_file = params.job_dir / "model.keras"
-
model.compile(optimizer=optimizer, loss=loss, metrics=metrics)
flops = nse.metrics.flops.get_flops(model, batch_size=1, fpath=params.job_dir / "model_flops.log")
- model(inputs)
model.summary(print_fn=logger.debug)
logger.debug(f"Model requires {flops/1e6:0.2f} MFLOPS")
+ val_mode = "max" if params.val_metric in ("f1", "cos") else "auto"
ModelCheckpoint = keras.callbacks.ModelCheckpoint
- if env_flag("WANDB"):
+ if nse.utils.env_flag("WANDB"):
ModelCheckpoint = WandbModelCheckpoint
model_callbacks = [
keras.callbacks.EarlyStopping(
monitor=f"val_{params.val_metric}",
patience=max(int(0.25 * params.epochs), 1),
- mode="max" if params.val_metric == "f1" else "auto",
+ mode=val_mode,
restore_best_weights=True,
verbose=min(params.verbose - 1, 1),
),
@@ -136,19 +108,19 @@ def train(params: HKTrainParams):
monitor=f"val_{params.val_metric}",
save_best_only=True,
save_weights_only=False,
- mode="max" if params.val_metric == "f1" else "auto",
+ mode=val_mode,
verbose=min(params.verbose - 1, 1),
),
keras.callbacks.CSVLogger(params.job_dir / "history.csv"),
]
- if env_flag("TENSORBOARD"):
+ if nse.utils.env_flag("TENSORBOARD"):
model_callbacks.append(
keras.callbacks.TensorBoard(
log_dir=params.job_dir,
write_steps_per_second=True,
)
)
- if env_flag("WANDB"):
+ if nse.utils.env_flag("WANDB"):
model_callbacks.append(WandbMetricsLogger())
try:
@@ -166,5 +138,8 @@ def train(params: HKTrainParams):
logger.debug(f"Model saved to {params.model_file}")
# Get full validation results
- keras.models.load_model(params.model_file)
logger.debug("Performing full validation")
+
+ # Summarize results
+ rst = model.evaluate(val_ds, return_dict=True)
+ logger.info("[VAL SET]" + ", ".join([f"{k.upper()}={v:.2%}" for k, v in rst.items()]))
diff --git a/heartkit/tasks/denoise/utils.py b/heartkit/tasks/denoise/utils.py
deleted file mode 100644
index dbf46a25..00000000
--- a/heartkit/tasks/denoise/utils.py
+++ /dev/null
@@ -1,107 +0,0 @@
-import keras
-from neuralspot_edge.models.tcn import Tcn, TcnBlockParams, TcnParams
-from rich.console import Console
-
-from ...defines import ModelArchitecture
-from ...models import ModelFactory
-
-console = Console()
-
-
-def create_model(inputs: keras.KerasTensor, num_classes: int, architecture: ModelArchitecture | None) -> keras.Model:
- """Generate model or use default
-
- Args:
- inputs (keras.KerasTensor): Model inputs
- num_classes (int): Number of classes
- architecture (ModelArchitecture|None): Model
-
- Returns:
- keras.Model: Model
- """
- if architecture:
- return ModelFactory.get(name=architecture.name)(
- x=inputs,
- params=architecture.params,
- num_classes=num_classes,
- )
-
- return _default_model(inputs=inputs, num_classes=num_classes)
-
-
-def _default_model(
- inputs: keras.KerasTensor,
- num_classes: int,
-) -> keras.Model:
- """Reference model
-
- Args:
- inputs (keras.KerasTensor): Model inputs
- num_classes (int): Number of classes
-
- Returns:
- keras.Model: Model
- """
- # Default model
-
- blocks = [
- TcnBlockParams(
- filters=8,
- kernel=(1, 7),
- dilation=(1, 1),
- dropout=0.1,
- ex_ratio=1,
- se_ratio=0,
- norm="batch",
- ),
- TcnBlockParams(
- filters=12,
- kernel=(1, 7),
- dilation=(1, 1),
- dropout=0.1,
- ex_ratio=1,
- se_ratio=2,
- norm="batch",
- ),
- TcnBlockParams(
- filters=16,
- kernel=(1, 7),
- dilation=(1, 2),
- dropout=0.1,
- ex_ratio=1,
- se_ratio=2,
- norm="batch",
- ),
- TcnBlockParams(
- filters=24,
- kernel=(1, 7),
- dilation=(1, 4),
- dropout=0.1,
- ex_ratio=1,
- se_ratio=2,
- norm="batch",
- ),
- TcnBlockParams(
- filters=32,
- kernel=(1, 7),
- dilation=(1, 8),
- dropout=0.1,
- ex_ratio=1,
- se_ratio=2,
- norm="batch",
- ),
- ]
-
- return Tcn(
- x=inputs,
- params=TcnParams(
- input_kernel=(1, 7),
- input_norm="batch",
- blocks=blocks,
- output_kernel=(1, 7),
- include_top=True,
- use_logits=True,
- model_name="tcn",
- ),
- num_classes=num_classes,
- )
diff --git a/heartkit/tasks/diagnostic/__init__.py b/heartkit/tasks/diagnostic/__init__.py
index 99cac1de..fb747bf8 100644
--- a/heartkit/tasks/diagnostic/__init__.py
+++ b/heartkit/tasks/diagnostic/__init__.py
@@ -1,4 +1,4 @@
-from ...defines import HKDemoParams, HKExportParams, HKTestParams, HKTrainParams
+from ...defines import HKTaskParams
from ..task import HKTask
from .defines import HKDiagnostic
from .demo import demo
@@ -11,17 +11,17 @@ class DiagnosticTask(HKTask):
"""HeartKit Diagnostic Task"""
@staticmethod
- def train(params: HKTrainParams):
+ def train(params: HKTaskParams):
train(params)
@staticmethod
- def evaluate(params: HKTestParams):
+ def evaluate(params: HKTaskParams):
evaluate(params)
@staticmethod
- def export(params: HKExportParams):
+ def export(params: HKTaskParams):
export(params)
@staticmethod
- def demo(params: HKDemoParams):
+ def demo(params: HKTaskParams):
demo(params)
diff --git a/heartkit/tasks/diagnostic/dataloaders/__init__.py b/heartkit/tasks/diagnostic/dataloaders/__init__.py
index cfa9101e..7f8196bb 100644
--- a/heartkit/tasks/diagnostic/dataloaders/__init__.py
+++ b/heartkit/tasks/diagnostic/dataloaders/__init__.py
@@ -1,2 +1,10 @@
-from .lsad import lsad_data_generator, lsad_label_map
-from .ptbxl import ptbxl_data_generator, ptbxl_label_map
+import neuralspot_edge as nse
+
+from ....datasets import HKDataloader
+
+from .ptbxl import PtbxlDataloader
+from .lsad import LsadDataloader
+
+DiagnosticDataloaderFactory = nse.utils.create_factory(factory="HKDiagnosticDataloaderFactory", type=HKDataloader)
+DiagnosticDataloaderFactory.register("ptbxl", PtbxlDataloader)
+DiagnosticDataloaderFactory.register("lsad", LsadDataloader)
diff --git a/heartkit/tasks/diagnostic/dataloaders/lsad.py b/heartkit/tasks/diagnostic/dataloaders/lsad.py
index 088ac70a..b258d1f5 100644
--- a/heartkit/tasks/diagnostic/dataloaders/lsad.py
+++ b/heartkit/tasks/diagnostic/dataloaders/lsad.py
@@ -1,9 +1,9 @@
from typing import Generator
import numpy.typing as npt
+import neuralspot_edge as nse
-from ....datasets.defines import PatientGenerator
-from ....datasets.lsad import LsadDataset, LsadScpCode
+from ....datasets import LsadDataset, LsadScpCode, HKDataloader
from ..defines import HKDiagnostic
LsadDiagnosticMap = {
@@ -48,50 +48,26 @@
}
-def lsad_label_map(
- label_map: dict[int, int] | None = None,
-) -> dict[int, int]:
- """Get label map
+class LsadDataloader(HKDataloader):
+ def __init__(self, ds: LsadDataset, **kwargs):
+ super().__init__(ds=ds, **kwargs)
+ if self.label_map:
+ self.label_map = {k: self.label_map[v] for (k, v) in LsadDiagnosticMap.items() if v in self.label_map}
- Args:
- label_map (dict[int, int]|None): Label map
+ self.label_type = "scp"
- Returns:
- dict[int, int]: Label map
- """
- return {k: label_map.get(v, -1) for (k, v) in LsadDiagnosticMap.items()}
-
-
-def lsad_data_generator(
- patient_generator: PatientGenerator,
- ds: LsadDataset,
- frame_size: int,
- samples_per_patient: int | list[int] = 1,
- target_rate: int | None = None,
- label_map: dict[int, int] | None = None,
-) -> Generator[tuple[npt.NDArray, npt.NDArray], None, None]:
- """Generate frames w/ diagnostic labels using patient generator.
-
- Args:
- patient_generator (PatientGenerator): Patient Generator
- ds: LsadDataset
- frame_size (int): Frame size
- samples_per_patient (int | list[int], optional): # samples per patient. Defaults to 1.
- target_rate (int|None, optional): Target rate. Defaults to None.
- label_map (dict[int, int] | None, optional): Label map. Defaults to None.
-
- Returns:
- Generator[tuple[npt.NDArray, npt.NDArray], None, None]: Sample generator
-
- """
- tgt_map = lsad_label_map(label_map=label_map)
-
- return ds.signal_label_generator(
- patient_generator=patient_generator,
- frame_size=frame_size,
- samples_per_patient=samples_per_patient,
- target_rate=target_rate,
- label_map=tgt_map,
- label_type="scp",
- label_format="multi_hot",
- )
+ def data_generator(
+ self,
+ patient_ids: list[int],
+ samples_per_patient: int | list[int],
+ shuffle: bool = False,
+ ) -> Generator[tuple[npt.NDArray, npt.NDArray], None, None]:
+ return self.ds.signal_label_generator(
+ patient_generator=nse.utils.uniform_id_generator(patient_ids, repeat=True, shuffle=shuffle),
+ frame_size=self.frame_size,
+ samples_per_patient=samples_per_patient,
+ target_rate=self.sampling_rate,
+ label_map=self.label_map,
+ label_type=self.label_type,
+ label_format="multi_hot",
+ )
diff --git a/heartkit/tasks/diagnostic/dataloaders/ptbxl.py b/heartkit/tasks/diagnostic/dataloaders/ptbxl.py
index 30caff39..961addf3 100644
--- a/heartkit/tasks/diagnostic/dataloaders/ptbxl.py
+++ b/heartkit/tasks/diagnostic/dataloaders/ptbxl.py
@@ -1,9 +1,9 @@
from typing import Generator
import numpy.typing as npt
+import neuralspot_edge as nse
-from ....datasets.defines import PatientGenerator
-from ....datasets.ptbxl import PtbxlDataset, PtbxlScpCode
+from ....datasets import PtbxlDataset, PtbxlScpCode, HKDataloader
from ..defines import HKDiagnostic
PtbxlDiagnosticMap = {
@@ -59,50 +59,26 @@
}
-def ptbxl_label_map(
- label_map: dict[int, int] | None = None,
-) -> dict[int, int]:
- """Get label map
+class PtbxlDataloader(HKDataloader):
+ def __init__(self, ds: PtbxlDataset, **kwargs):
+ super().__init__(ds=ds, **kwargs)
+ if self.label_map:
+ self.label_map = {k: self.label_map[v] for (k, v) in PtbxlDiagnosticMap.items() if v in self.label_map}
- Args:
- label_map (dict[int, int]|None): Label map
+ self.label_type = "scp"
- Returns:
- dict[int, int]: Label map
- """
- return {k: label_map.get(v, -1) for (k, v) in PtbxlDiagnosticMap.items()}
-
-
-def ptbxl_data_generator(
- patient_generator: PatientGenerator,
- ds: PtbxlDataset,
- frame_size: int,
- samples_per_patient: int | list[int] = 1,
- target_rate: int | None = None,
- label_map: dict[int, int] | None = None,
-) -> Generator[tuple[npt.NDArray, npt.NDArray], None, None]:
- """Generate frames w/ diagnostic labels using patient generator.
-
- Args:
- patient_generator (PatientGenerator): Patient Generator
- ds: PtbxlDataset
- frame_size (int): Frame size
- samples_per_patient (int | list[int], optional): # samples per patient. Defaults to 1.
- target_rate (int|None, optional): Target rate. Defaults to None.
- label_map (dict[int, int] | None, optional): Label map. Defaults to None.
-
- Returns:
- Generator[tuple[npt.NDArray, npt.NDArray], None, None]: Sample generator
-
- """
- tgt_map = ptbxl_label_map(label_map=label_map)
-
- return ds.signal_label_generator(
- patient_generator=patient_generator,
- frame_size=frame_size,
- samples_per_patient=samples_per_patient,
- target_rate=target_rate,
- label_map=tgt_map,
- label_type="scp",
- label_format="multi_hot",
- )
+ def data_generator(
+ self,
+ patient_ids: list[int],
+ samples_per_patient: int | list[int],
+ shuffle: bool = False,
+ ) -> Generator[tuple[npt.NDArray, npt.NDArray], None, None]:
+ return self.ds.signal_label_generator(
+ patient_generator=nse.utils.uniform_id_generator(patient_ids, repeat=True, shuffle=shuffle),
+ frame_size=self.frame_size,
+ samples_per_patient=samples_per_patient,
+ target_rate=self.sampling_rate,
+ label_map=self.label_map,
+ label_type=self.label_type,
+ label_format="multi_hot",
+ )
diff --git a/heartkit/tasks/diagnostic/datasets.py b/heartkit/tasks/diagnostic/datasets.py
index 853cced1..c9bf4f7a 100644
--- a/heartkit/tasks/diagnostic/datasets.py
+++ b/heartkit/tasks/diagnostic/datasets.py
@@ -1,351 +1,159 @@
-import functools
-import logging
-from pathlib import Path
-
import numpy as np
-import numpy.typing as npt
import tensorflow as tf
+import neuralspot_edge as nse
from ...datasets import (
HKDataset,
- augment_pipeline,
- preprocess_pipeline,
- uniform_id_generator,
-)
-from ...datasets.dataloader import test_dataloader, train_val_dataloader
-from ...defines import (
- AugmentationParams,
- HKExportParams,
- HKTestParams,
- HKTrainParams,
- PreprocessParams,
-)
-from ...utils import resolve_template_path
-from .dataloaders import (
- lsad_data_generator,
- lsad_label_map,
- ptbxl_data_generator,
- ptbxl_label_map,
+ create_augmentation_pipeline,
)
+from ...datasets.dataloader import HKDataloader
+from ...defines import HKTaskParams, NamedParams
-logger = logging.getLogger(__name__)
-
-
-def preprocess(x: npt.NDArray, preprocesses: list[PreprocessParams], sample_rate: float) -> npt.NDArray:
- """Preprocess data pipeline
-
- Args:
- x (npt.NDArray): Input data
- preprocesses (list[PreprocessParams]): Preprocess parameters
- sample_rate (float): Sample rate
-
- Returns:
- npt.NDArray: Preprocessed data
- """
- return preprocess_pipeline(x, preprocesses=preprocesses, sample_rate=sample_rate)
-
-
-def augment(x: npt.NDArray, augmentations: list[AugmentationParams], sample_rate: float) -> npt.NDArray:
- """Augment data pipeline
-
- Args:
- x (npt.NDArray): Input data
- augmentations (list[AugmentationParams]): Augmentation parameters
- sample_rate (float): Sample rate
-
- Returns:
- npt.NDArray: Augmented data
- """
-
- return augment_pipeline(x=x, augmentations=augmentations, sample_rate=sample_rate)
-
-
-def prepare(
- x_y: tuple[npt.NDArray, npt.NDArray],
- sample_rate: float,
- preprocesses: list[PreprocessParams],
- augmentations: list[AugmentationParams],
- spec: tuple[tf.TensorSpec, tf.TensorSpec],
- num_classes: int,
-) -> tuple[npt.NDArray, npt.NDArray]:
- """Prepare dataset
-
- Args:
- x_y (tuple[npt.NDArray, int]): Data and label
- sample_rate (float): Sample rate
- preprocesses (list[PreprocessParams]|None): Preprocess parameters
- augmentations (list[AugmentationParams]|None): Augmentation parameters
- spec (tuple[tf.TensorSpec, tf.TensorSpec]): TensorSpec
- num_classes (int): Number of classes
-
- Returns:
- tuple[npt.NDArray, npt.NDArray]: Data and label
- """
-
- x, y = x_y[0].copy(), x_y[1]
-
- if augmentations:
- x = augment(x, augmentations, sample_rate)
- # END IF
-
- if preprocesses:
- x = preprocess(x, preprocesses, sample_rate)
- # END IF
-
- x = x.reshape(spec[0].shape)
- # y is already multi-hot encoded
+from .dataloaders import DiagnosticDataloaderFactory
- return x, y
+logger = nse.utils.setup_logger(__name__)
-def get_ds_label_map(ds: HKDataset, label_map: dict[int, int] | None = None) -> dict[int, int]:
- """Get label map for dataset
-
- Args:
- ds (HKDataset): Dataset
- label_map (dict[int, int]|None): Label map
-
- Returns:
- dict[int, int]: Label map
- """
- match ds.name:
- case "lsad":
- return lsad_label_map(label_map=label_map)
- case "ptbxl":
- return ptbxl_label_map(label_map=label_map)
- case _:
- raise ValueError(f"Dataset {ds.name} not supported")
- # END MATCH
-
-
-def get_ds_generator(
- ds: HKDataset,
- frame_size: int,
- samples_per_patient: int,
- target_rate: int,
- label_map: dict[int, int] | None = None,
+def create_data_pipeline(
+ ds: tf.data.Dataset,
+ sampling_rate: int,
+ batch_size: int,
+ buffer_size: int | None = None,
+ augmentations: list[NamedParams] | None = None,
):
- """Get task data generator for dataset
-
- Args:
- ds (HKDataset): Dataset
- frame_size (int): Frame size
- samples_per_patient (int): Samples per patient
- target_rate (int): Target rate
-
- Returns:
- callable: Data generator
- """
- match ds.name:
- case "lsad":
- data_generator = lsad_data_generator
- case "ptbxl":
- data_generator = ptbxl_data_generator
- case _:
- raise ValueError(f"Dataset {ds.name} not supported")
- # END MATCH
- return functools.partial(
- data_generator,
- ds=ds,
- frame_size=frame_size,
- samples_per_patient=samples_per_patient,
- target_rate=target_rate,
- label_map=label_map,
+ if buffer_size:
+ ds = ds.shuffle(
+ buffer_size=buffer_size,
+ reshuffle_each_iteration=True,
+ )
+ if batch_size:
+ ds = ds.batch(
+ batch_size=batch_size,
+ drop_remainder=True,
+ num_parallel_calls=tf.data.AUTOTUNE,
+ )
+ augmenter = create_augmentation_pipeline(augmentations, sampling_rate=sampling_rate)
+ ds = (
+ ds.map(
+ lambda data, labels: {
+ "data": tf.cast(data, "float32"),
+ "labels": labels, # Already multi-hot encoded
+ },
+ num_parallel_calls=tf.data.AUTOTUNE,
+ )
+ .map(
+ augmenter,
+ num_parallel_calls=tf.data.AUTOTUNE,
+ )
+ .map(
+ lambda data: (data["data"], data["labels"]),
+ num_parallel_calls=tf.data.AUTOTUNE,
+ )
)
-
-def get_ds_label_type(ds: HKDataset) -> str:
- """Get label type for dataset
-
- Args:
- ds (HKDataset): Dataset
-
- Returns:
- str: Label type
- """
- return "scp"
-
-
-def resolve_ds_cache_path(fpath: Path | None, ds: HKDataset, task: str, frame_size: int, sample_rate: int):
- """Resolve dataset cache path
-
- Args:
- fpath (Path|None): File path
- ds (HKDataset): Dataset
- task (str): Task
- frame_size (int): Frame size
- sample_rate (int): Sampling rate
-
- Returns:
- Path|None: Resolved path
- """
- if not fpath:
- return None
- return resolve_template_path(
- fpath=fpath,
- dataset=ds.name,
- task=task,
- frame_size=frame_size,
- sampling_rate=sample_rate,
- )
+ return ds.prefetch(tf.data.AUTOTUNE)
def load_train_datasets(
datasets: list[HKDataset],
- params: HKTrainParams,
- ds_spec: tuple[tf.TensorSpec, tf.TensorSpec],
+ params: HKTaskParams,
) -> tuple[tf.data.Dataset, tf.data.Dataset]:
- """Load training and validation datasets
-
- Args:
- datasets (list[HKDataset]): Datasets
- params (HKTrainParams): Training parameters
- ds_spec (tuple[tf.TensorSpec, tf.TensorSpec]): TensorSpec
-
- Returns:
- tuple[tf.data.Dataset, tf.data.Dataset]: Train and validation datasets
- """
- id_generator = functools.partial(uniform_id_generator, repeat=True)
- train_prepare = functools.partial(
- prepare,
- sample_rate=params.sampling_rate,
- preprocesses=params.preprocesses,
- augmentations=params.augmentations,
- spec=ds_spec,
- num_classes=params.num_classes,
- )
- val_prepare = functools.partial(
- prepare,
- sample_rate=params.sampling_rate,
- preprocesses=params.preprocesses,
- augmentations=None,
- spec=ds_spec,
- num_classes=params.num_classes,
- )
-
train_datasets = []
val_datasets = []
for ds in datasets:
- val_file = resolve_ds_cache_path(
- params.val_file,
+ dataloader: HKDataloader = DiagnosticDataloaderFactory.get(ds.name)(
ds=ds,
- task="diagnostic",
frame_size=params.frame_size,
- sample_rate=params.sampling_rate,
- )
- data_generator = get_ds_generator(
- ds=ds,
- frame_size=params.frame_size,
- samples_per_patient=params.samples_per_patient,
- target_rate=params.sampling_rate,
+ sampling_rate=params.sampling_rate,
label_map=params.class_map,
)
- train_ds, val_ds = train_val_dataloader(
- ds=ds,
- spec=ds_spec,
- data_generator=data_generator,
- id_generator=id_generator,
+ train_patients, val_patients = dataloader.split_train_val_patients(
train_patients=params.train_patients,
val_patients=params.val_patients,
- val_pt_samples=params.val_samples_per_patient,
- val_file=val_file,
- val_size=params.val_size,
- label_map=get_ds_label_map(ds, params.class_map),
- label_type=get_ds_label_type(ds),
- preprocess=train_prepare,
- val_preprocess=val_prepare,
- num_workers=params.data_parallelism,
+ )
+
+ train_ds = dataloader.create_dataloader(
+ patient_ids=train_patients, samples_per_patient=params.samples_per_patient, shuffle=True
+ )
+
+ val_ds = dataloader.create_dataloader(
+ patient_ids=val_patients, samples_per_patient=params.val_samples_per_patient, shuffle=False
)
train_datasets.append(train_ds)
val_datasets.append(val_ds)
# END FOR
- ds_weights = np.array([d.weight for d in params.datasets])
- ds_weights = ds_weights / ds_weights.sum()
+ ds_weights = None
+ if params.dataset_weights:
+ ds_weights = np.array(params.dataset_weights)
+ ds_weights = ds_weights / ds_weights.sum()
train_ds = tf.data.Dataset.sample_from_datasets(train_datasets, weights=ds_weights)
val_ds = tf.data.Dataset.sample_from_datasets(val_datasets, weights=ds_weights)
# Shuffle and batch datasets for training
- train_ds = (
- train_ds.shuffle(
- buffer_size=params.buffer_size,
- reshuffle_each_iteration=True,
- )
- .batch(
- batch_size=params.batch_size,
- drop_remainder=False,
- num_parallel_calls=tf.data.AUTOTUNE,
- )
- .prefetch(buffer_size=tf.data.AUTOTUNE)
+ train_ds = create_data_pipeline(
+ ds=train_ds,
+ sampling_rate=params.sampling_rate,
+ batch_size=params.batch_size,
+ buffer_size=params.buffer_size,
+ augmentations=params.augmentations + params.preprocesses,
)
- val_ds = val_ds.batch(
+
+ val_ds = create_data_pipeline(
+ ds=val_ds,
+ sampling_rate=params.sampling_rate,
batch_size=params.batch_size,
- drop_remainder=True,
- num_parallel_calls=tf.data.AUTOTUNE,
+ buffer_size=params.buffer_size,
+ augmentations=params.preprocesses,
)
+
+ # If given fixed val size or steps, then capture and cache
+ val_steps_per_epoch = params.val_size // params.batch_size if params.val_size else params.val_steps_per_epoch
+ if val_steps_per_epoch:
+ logger.info(f"Validation steps per epoch: {val_steps_per_epoch}")
+ val_ds = val_ds.take(val_steps_per_epoch).cache()
+
return train_ds, val_ds
def load_test_dataset(
datasets: list[HKDataset],
- params: HKTestParams | HKExportParams,
- ds_spec: tuple[tf.TensorSpec, tf.TensorSpec],
+ params: HKTaskParams,
) -> tf.data.Dataset:
- """Load test dataset
-
- Args:
- datasets (list[HKDataset]): Datasets
- params (HKTestParams|HKExportParams): Test parameters
- ds_spec (tuple[tf.TensorSpec, tf.TensorSpec]): TensorSpec
-
- Returns:
- tf.data.Dataset: Test dataset
- """
-
- id_generator = functools.partial(uniform_id_generator, repeat=True)
- test_prepare = functools.partial(
- prepare,
- sample_rate=params.sampling_rate,
- preprocesses=params.preprocesses,
- augmentations=None, # params.augmentations,
- spec=ds_spec,
- num_classes=params.num_classes,
- )
test_datasets = []
for ds in datasets:
- test_file = resolve_ds_cache_path(
- fpath=params.test_file,
+ dataloader: HKDataloader = DiagnosticDataloaderFactory.get(ds.name)(
ds=ds,
- task="diagnostic",
frame_size=params.frame_size,
- sample_rate=params.sampling_rate,
+ sampling_rate=params.sampling_rate,
+ label_map=params.class_map,
)
- data_generator = get_ds_generator(
- ds=ds,
- frame_size=params.frame_size,
+ test_patients = dataloader.test_patient_ids(params.test_patients)
+ test_ds = dataloader.create_dataloader(
+ patient_ids=test_patients,
samples_per_patient=params.test_samples_per_patient,
- target_rate=params.sampling_rate,
- )
- test_ds = test_dataloader(
- ds=ds,
- spec=ds_spec,
- data_generator=data_generator,
- id_generator=id_generator,
- test_patients=params.test_patients,
- test_file=test_file,
- label_map=get_ds_label_map(ds, params.class_map),
- label_type=get_ds_label_type(ds),
- preprocess=test_prepare,
- num_workers=params.data_parallelism,
+ shuffle=False,
)
test_datasets.append(test_ds)
# END FOR
- ds_weights = np.array([d.weight for d in params.datasets])
- ds_weights = ds_weights / ds_weights.sum()
+ ds_weights = None
+ if params.dataset_weights:
+ ds_weights = np.array(params.dataset_weights)
+ ds_weights = ds_weights / ds_weights.sum()
test_ds = tf.data.Dataset.sample_from_datasets(test_datasets, weights=ds_weights)
- # END WITH
+ test_ds = create_data_pipeline(
+ ds=test_ds,
+ sampling_rate=params.sampling_rate,
+ batch_size=params.batch_size,
+ augmentations=params.preprocesses,
+ )
+
+ if params.test_size:
+ batch_size = getattr(params, "batch_size", 1)
+ test_ds = test_ds.take(params.test_size // batch_size).cache()
+
return test_ds
diff --git a/heartkit/tasks/diagnostic/demo.py b/heartkit/tasks/diagnostic/demo.py
index c3219c29..491ff7cb 100644
--- a/heartkit/tasks/diagnostic/demo.py
+++ b/heartkit/tasks/diagnostic/demo.py
@@ -5,22 +5,21 @@
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from tqdm import tqdm
+import neuralspot_edge as nse
-from ...datasets.utils import uniform_id_generator
-from ...defines import HKDemoParams
+from ...defines import HKTaskParams
from ...rpc import BackendFactory
-from ...utils import setup_logger
-from ..utils import load_datasets
-from .datasets import preprocess
+from ...datasets import DatasetFactory
-logger = setup_logger(__name__)
+logger = nse.utils.setup_logger(__name__)
-def demo(params: HKDemoParams):
+
+def demo(params: HKTaskParams):
"""Run demo for model
Args:
- params (HKDemoParams): Demo parameters
+ params (HKTaskParams): Demo parameters
"""
bg_color = "rgba(38,42,50,1.0)"
@@ -31,7 +30,7 @@ def demo(params: HKDemoParams):
params.demo_size = params.demo_size or 2 * params.frame_size
# Load backend inference engine
- runner = BackendFactory.create(params.backend, params=params)
+ runner = BackendFactory.get(params.backend)(params=params)
# classes = sorted(list(set(params.class_map.values())))
class_names = params.class_names or [f"Class {i}" for i in range(params.num_classes)]
@@ -45,11 +44,11 @@ def demo(params: HKDemoParams):
# )
# Load data
- dsets = load_datasets(datasets=params.datasets)
- ds = random.choice(dsets)
+ datasets = [DatasetFactory.get(ds.name)(**ds.params) for ds in params.datasets]
+ ds = random.choice(datasets)
ds_gen = ds.signal_generator(
- patient_generator=uniform_id_generator(ds.get_test_patient_ids(), repeat=False),
+ patient_generator=nse.utils.uniform_id_generator(ds.get_test_patient_ids(), repeat=False),
frame_size=params.demo_size,
samples_per_patient=5,
target_rate=params.sampling_rate,
@@ -65,7 +64,8 @@ def demo(params: HKDemoParams):
start, stop = x.shape[0] - params.frame_size, x.shape[0]
else:
start, stop = i, i + params.frame_size
- xx = preprocess(x[start:stop], sample_rate=params.sampling_rate, preprocesses=params.preprocesses)
+ # xx = preprocess(x[start:stop], sample_rate=params.sampling_rate, preprocesses=params.preprocesses)
+ xx = x[start:stop]
xx = xx.reshape(feat_shape)
runner.set_inputs(xx)
runner.perform_inference()
diff --git a/heartkit/tasks/diagnostic/evaluate.py b/heartkit/tasks/diagnostic/evaluate.py
index f1f52972..94b5593f 100644
--- a/heartkit/tasks/diagnostic/evaluate.py
+++ b/heartkit/tasks/diagnostic/evaluate.py
@@ -1,53 +1,37 @@
-import logging
import os
import numpy as np
import pandas as pd
-import tensorflow as tf
from sklearn.metrics import classification_report, f1_score
-
import neuralspot_edge as nse
-from ...defines import HKTestParams
-from ...utils import set_random_seed, setup_logger
-from ..utils import load_datasets
-from .datasets import load_test_dataset
-logger = setup_logger(__name__)
+from ...defines import HKTaskParams
+from ...datasets import DatasetFactory
+from .datasets import load_test_dataset
-def evaluate(params: HKTestParams):
+def evaluate(params: HKTaskParams):
"""Evaluate model
Args:
- params (HKTestParams): Evaluation parameters
+ params (HKTaskParams): Evaluation parameters
"""
- params.threshold = params.threshold or 0.5
-
- params.seed = set_random_seed(params.seed)
- logger.debug(f"Random seed {params.seed}")
-
os.makedirs(params.job_dir, exist_ok=True)
+ logger = nse.utils.setup_logger(__name__, level=params.verbose, file_path=params.job_dir / "test.log")
logger.debug(f"Creating working directory in {params.job_dir}")
- handler = logging.FileHandler(params.job_dir / "test.log", mode="w")
- handler.setLevel(logging.INFO)
- logger.addHandler(handler)
-
- # classes = sorted(list(set(params.class_map.values())))
- class_names = params.class_names or [f"Class {i}" for i in range(params.num_classes)]
+ params.threshold = params.threshold or 0.5
- feat_shape = (params.frame_size, 1)
- class_shape = (params.num_classes,)
+ params.seed = nse.utils.set_random_seed(params.seed)
+ logger.debug(f"Random seed {params.seed}")
- ds_spec = (
- tf.TensorSpec(shape=feat_shape, dtype=tf.float32),
- tf.TensorSpec(shape=class_shape, dtype=tf.int32),
- )
+ class_names = params.class_names or [f"Class {i}" for i in range(params.num_classes)]
- datasets = load_datasets(datasets=params.datasets)
+ datasets = [DatasetFactory.get(ds.name)(**ds.params) for ds in params.datasets]
- test_ds = load_test_dataset(datasets=datasets, params=params, ds_spec=ds_spec)
- test_x, test_y = next(test_ds.batch(params.test_size).as_numpy_iterator())
+ test_ds = load_test_dataset(datasets=datasets, params=params)
+ test_x = np.concatenate([x for x, _ in test_ds.as_numpy_iterator()])
+ test_y = np.concatenate([y for _, y in test_ds.as_numpy_iterator()])
logger.debug("Loading model")
model = nse.models.load_model(params.model_file)
@@ -62,7 +46,7 @@ def evaluate(params: HKTestParams):
y_pred = y_prob >= params.threshold
cm_path = params.job_dir / "confusion_matrix_test.png"
- nse.plotting.cm.multilabel_confusion_matrix_plot(
+ nse.plotting.multilabel_confusion_matrix_plot(
y_true=y_true,
y_pred=y_pred,
labels=class_names,
diff --git a/heartkit/tasks/diagnostic/export.py b/heartkit/tasks/diagnostic/export.py
index 28a0a032..3e202cc0 100644
--- a/heartkit/tasks/diagnostic/export.py
+++ b/heartkit/tasks/diagnostic/export.py
@@ -1,59 +1,43 @@
-import logging
import os
import shutil
-import keras
import numpy as np
-import tensorflow as tf
-from sklearn.metrics import f1_score
-
+import keras
import neuralspot_edge as nse
-from ...defines import HKExportParams
-from ...utils import setup_logger
-from ..utils import load_datasets
-from .datasets import load_test_dataset
-logger = setup_logger(__name__)
+from ...defines import HKTaskParams
+from ...datasets import DatasetFactory
+from .datasets import load_test_dataset
-def export(params: HKExportParams):
+def export(params: HKTaskParams):
"""Export model
Args:
- params (HKExportParams): Deployment parameters
+ params (HKTaskParams): Deployment parameters
"""
- params.threshold = params.threshold or 0.5
-
os.makedirs(params.job_dir, exist_ok=True)
+ logger = nse.utils.setup_logger(__name__, level=params.verbose, file_path=params.job_dir / "export.log")
logger.debug(f"Creating working directory in {params.job_dir}")
- handler = logging.FileHandler(params.job_dir / "export.log", mode="w")
- handler.setLevel(logging.INFO)
- logger.addHandler(handler)
+ params.threshold = params.threshold or 0.5
tfl_model_path = params.job_dir / "model.tflite"
tflm_model_path = params.job_dir / "model_buffer.h"
- # classes = sorted(list(set(params.class_map.values())))
- # class_names = params.class_names or [f"Class {i}" for i in range(params.num_classes)]
-
feat_shape = (params.frame_size, 1)
- class_shape = (params.num_classes,)
-
- ds_spec = (
- tf.TensorSpec(shape=feat_shape, dtype="float32"),
- tf.TensorSpec(shape=class_shape, dtype="int32"),
- )
- datasets = load_datasets(datasets=params.datasets)
+ datasets = [DatasetFactory.get(ds.name)(**ds.params) for ds in params.datasets]
- test_ds = load_test_dataset(datasets=datasets, params=params, ds_spec=ds_spec)
- test_x, test_y = next(test_ds.batch(params.test_size).as_numpy_iterator())
+ test_ds = load_test_dataset(datasets=datasets, params=params)
+ test_x = np.concatenate([x for x, _ in test_ds.as_numpy_iterator()])
+ test_y = np.concatenate([y for _, y in test_ds.as_numpy_iterator()])
# Load model and set fixed batch size of 1
+ logger.debug("Loading trained model")
model = nse.models.load_model(params.model_file)
- inputs = keras.Input(shape=ds_spec[0].shape, batch_size=1, name="input", dtype=ds_spec[0].dtype)
+ inputs = keras.Input(shape=feat_shape, batch_size=1, name="input", dtype="float32")
model(inputs)
flops = nse.metrics.flops.get_flops(model, batch_size=1, fpath=params.job_dir / "model_flops.log")
@@ -62,6 +46,7 @@ def export(params: HKExportParams):
logger.debug(f"Converting model to TFLite (quantization={params.quantization.mode})")
converter = nse.converters.tflite.TfLiteKerasConverter(model=model)
+
tflite_content = converter.convert(
test_x=test_x,
quantization=params.quantization.format,
@@ -87,25 +72,28 @@ def export(params: HKExportParams):
tflite.compile()
# Verify TFLite results match TF results
- logger.debug("Validating model results")
+ metrics = [keras.metrics.CategoricalAccuracy(name="acc"), keras.metrics.F1Score(name="f1", average="weighted")]
+
+ if params.val_metric not in [m.name for m in metrics]:
+ raise ValueError(f"Metric {params.val_metric} not supported")
+
+ logger.info("Validating model results")
y_true = test_y
- y_pred_tf = model.predict(test_x) >= params.threshold
- y_pred_tfl = tflite.predict(x=test_x) >= params.threshold
+ y_pred_tf = model.predict(test_x)
+ y_pred_tfl = tflite.predict(x=test_x)
- tf_acc = np.sum(y_true == y_pred_tf) / y_true.size
- tf_f1 = f1_score(y_true, y_pred_tf, average="weighted")
- logger.info(f"[TF SET] ACC={tf_acc:.2%}, F1={tf_f1:.2%}")
+ tf_rst = nse.metrics.compute_metrics(metrics, y_true, y_pred_tf)
+ tfl_rst = nse.metrics.compute_metrics(metrics, y_true, y_pred_tfl)
+ logger.info("[TF METRICS] " + " ".join([f"{k.upper()}={v:.2%}" for k, v in tf_rst.items()]))
+ logger.info("[TFL METRICS] " + " ".join([f"{k.upper()}={v:.2%}" for k, v in tfl_rst.items()]))
- tfl_acc = np.sum(y_true == y_pred_tfl) / y_true.size
- tfl_f1 = f1_score(y_true, y_pred_tfl, average="weighted")
- logger.info(f"[TFL SET] ACC={tfl_acc:.2%}, F1={tfl_f1:.2%}")
+ metric_diff = abs(tf_rst[params.val_metric] - tfl_rst[params.val_metric])
# Check accuracy hit
- tfl_acc_drop = max(0, tf_acc - tfl_acc)
- if params.val_acc_threshold is not None and (1 - tfl_acc_drop) < params.val_acc_threshold:
- logger.warning(f"TFLite accuracy dropped by {tfl_acc_drop:0.2%}")
- elif params.val_acc_threshold:
- logger.info(f"Validation passed ({tfl_acc_drop:0.2%})")
+ if params.val_metric_threshold is not None and metric_diff > params.val_metric_threshold:
+ logger.warning(f"TFLite accuracy dropped by {metric_diff:0.2%}")
+ elif params.val_metric_threshold:
+ logger.info(f"Validation passed ({metric_diff:0.2%})")
if params.tflm_file and tflm_model_path != params.tflm_file:
logger.debug(f"Copying TFLM header to {params.tflm_file}")
diff --git a/heartkit/tasks/diagnostic/train.py b/heartkit/tasks/diagnostic/train.py
index db894684..0dd73f6c 100644
--- a/heartkit/tasks/diagnostic/train.py
+++ b/heartkit/tasks/diagnostic/train.py
@@ -1,90 +1,66 @@
-import logging
import os
import keras
import numpy as np
import pandas as pd
-import tensorflow as tf
import wandb
from sklearn.metrics import classification_report, f1_score
from wandb.keras import WandbMetricsLogger, WandbModelCheckpoint
import neuralspot_edge as nse
-from ...defines import HKTrainParams
-from ...utils import env_flag, set_random_seed, setup_logger
-from ..utils import load_datasets
-from .datasets import load_train_datasets
-from .utils import create_model
-logger = setup_logger(__name__)
+from ...defines import HKTaskParams
+from ...datasets import DatasetFactory
+from .datasets import load_train_datasets
+from ...models import ModelFactory
-def train(params: HKTrainParams):
+def train(params: HKTaskParams):
"""Train model
Args:
- params (HKTrainParams): Training parameters
+ params (HKTaskParams): Training parameters
"""
- params.threshold = params.threshold or 0.5
-
- params.seed = set_random_seed(params.seed)
- logger.debug(f"Random seed {params.seed}")
-
os.makedirs(params.job_dir, exist_ok=True)
+ logger = nse.utils.setup_logger(__name__, level=params.verbose, file_path=params.job_dir / "train.log")
logger.debug(f"Creating working directory in {params.job_dir}")
- handler = logging.FileHandler(params.job_dir / "train.log", mode="w")
- handler.setLevel(logging.INFO)
- logger.addHandler(handler)
+ params.threshold = params.threshold or 0.5
+
+ params.seed = nse.utils.set_random_seed(params.seed)
+ logger.debug(f"Random seed {params.seed}")
with open(params.job_dir / "train_config.json", "w", encoding="utf-8") as fp:
fp.write(params.model_dump_json(indent=2))
- if env_flag("WANDB"):
- wandb.init(
- project=params.project,
- entity="ambiq",
- dir=params.job_dir,
- )
+ if nse.utils.env_flag("WANDB"):
+ wandb.init(project=params.project, entity="ambiq", dir=params.job_dir)
wandb.config.update(params.model_dump())
# END IF
- # classes = sorted(list(set(params.class_map.values())))
class_names = params.class_names or [f"Class {i}" for i in range(params.num_classes)]
feat_shape = (params.frame_size, 1)
- class_shape = (params.num_classes,)
- ds_spec = (
- tf.TensorSpec(shape=feat_shape, dtype="float32"),
- tf.TensorSpec(shape=class_shape, dtype="int32"),
- )
-
- datasets = load_datasets(datasets=params.datasets)
+ datasets = [DatasetFactory.get(ds.name)(**ds.params) for ds in params.datasets]
train_ds, val_ds = load_train_datasets(
datasets=datasets,
params=params,
- ds_spec=ds_spec,
)
- test_labels = np.array([label.numpy() for _, label in val_ds])
- y_true = np.concatenate(test_labels)
+ y_true = np.concatenate([y for _, y in val_ds.as_numpy_iterator()])
class_weights = 0.25
if params.class_weights == "balanced":
n_samples = np.sum(y_true)
class_weights = n_samples / (params.num_classes * np.sum(y_true, axis=0))
class_weights = (class_weights + class_weights.mean()) / 2 # Smooth out
+ class_weights = class_weights.tolist()
# END IF
logger.debug(f"Class weights: {class_weights}")
- inputs = keras.Input(
- shape=ds_spec[0].shape,
- batch_size=None,
- name="input",
- dtype=ds_spec[0].dtype.name,
- )
+ inputs = keras.Input(shape=feat_shape, name="input", dtype="float32")
if params.resume and params.model_file:
logger.debug(f"Loading model from file {params.model_file}")
@@ -92,39 +68,31 @@ def train(params: HKTrainParams):
params.model_file = None
else:
logger.debug("Creating model from scratch")
- model = create_model(
- inputs,
+ if params.architecture is None:
+ raise ValueError("Model architecture must be specified")
+ model = ModelFactory.get(params.architecture.name)(
+ x=inputs,
+ params=params.architecture.params,
num_classes=params.num_classes,
- architecture=params.architecture,
)
# END IF
flops = nse.metrics.flops.get_flops(model, batch_size=1, fpath=params.job_dir / "model_flops.log")
- if params.lr_cycles > 1:
- scheduler = keras.optimizers.schedules.CosineDecayRestarts(
- initial_learning_rate=params.lr_rate,
- first_decay_steps=int(0.1 * params.steps_per_epoch * params.epochs),
- t_mul=1.65 / (0.1 * params.lr_cycles * (params.lr_cycles - 1)),
- m_mul=0.4,
- )
- else:
- scheduler = keras.optimizers.schedules.CosineDecay(
- initial_learning_rate=params.lr_rate,
- decay_steps=params.steps_per_epoch * params.epochs,
- )
- # END IF
+ t_mul = 1
+ first_steps = (params.steps_per_epoch * params.epochs) / (np.power(params.lr_cycles, t_mul) - t_mul + 1)
+ scheduler = keras.optimizers.schedules.CosineDecayRestarts(
+ initial_learning_rate=params.lr_rate,
+ first_decay_steps=np.ceil(first_steps),
+ t_mul=t_mul,
+ m_mul=0.5,
+ )
optimizer = keras.optimizers.Adam(scheduler)
loss = keras.losses.BinaryCrossentropy(from_logits=True, label_smoothing=params.label_smoothing)
- # loss = keras.losses.BinaryFocalCrossentropy(
- # apply_class_balancing=False,
- # alpha=class_weights,
- # from_logits=True,
- # label_smoothing=params.label_smoothing,
- # )
+
metrics = [
keras.metrics.BinaryAccuracy(name="acc"),
- # tfa.MultiF1Score(name="f1", average="weighted"),
+ keras.metrics.F1Score(name="f1", average="weighted"),
]
if params.resume and params.weights_file:
@@ -135,12 +103,11 @@ def train(params: HKTrainParams):
params.model_file = params.job_dir / "model.keras"
model.compile(optimizer=optimizer, loss=loss, metrics=metrics)
- model(inputs)
model.summary(print_fn=logger.info)
logger.debug(f"Model requires {flops/1e6:0.2f} MFLOPS")
ModelCheckpoint = keras.callbacks.ModelCheckpoint
- if env_flag("WANDB"):
+ if nse.utils.env_flag("WANDB"):
ModelCheckpoint = WandbModelCheckpoint
model_callbacks = [
keras.callbacks.EarlyStopping(
@@ -148,31 +115,32 @@ def train(params: HKTrainParams):
patience=max(int(0.25 * params.epochs), 1),
mode="max" if params.val_metric == "f1" else "auto",
restore_best_weights=True,
+ verbose=params.verbose - 1,
),
ModelCheckpoint(
filepath=str(params.model_file),
monitor=f"val_{params.val_metric}",
save_best_only=True,
mode="max" if params.val_metric == "f1" else "auto",
- verbose=1,
+ verbose=params.verbose - 1,
),
keras.callbacks.CSVLogger(params.job_dir / "history.csv"),
]
- if env_flag("TENSORBOARD"):
+ if nse.utils.env_flag("TENSORBOARD"):
model_callbacks.append(
keras.callbacks.TensorBoard(
log_dir=params.job_dir,
write_steps_per_second=True,
)
)
- if env_flag("WANDB"):
+ if nse.utils.env_flag("WANDB"):
model_callbacks.append(WandbMetricsLogger())
try:
model.fit(
train_ds,
steps_per_epoch=params.steps_per_epoch,
- verbose=2,
+ verbose=params.verbose,
epochs=params.epochs,
validation_data=val_ds,
callbacks=model_callbacks,
@@ -183,13 +151,12 @@ def train(params: HKTrainParams):
logger.debug(f"Model saved to {params.model_file}")
# Get full validation results
- keras.models.load_model(params.model_file)
logger.debug("Performing full validation")
y_pred = model.predict(val_ds)
- y_pred = y_pred >= params.threshold
- cm_path = params.job_dir / "confusion_matrix.png"
+ # y_pred = y_pred >= params.threshold
- nse.plotting.cm.multilabel_confusion_matrix_plot(
+ cm_path = params.job_dir / "confusion_matrix.png"
+ nse.plotting.multilabel_confusion_matrix_plot(
y_true=y_true,
y_pred=y_pred,
labels=class_names,
diff --git a/heartkit/tasks/diagnostic/utils.py b/heartkit/tasks/diagnostic/utils.py
deleted file mode 100644
index a4554bd9..00000000
--- a/heartkit/tasks/diagnostic/utils.py
+++ /dev/null
@@ -1,97 +0,0 @@
-import keras
-from neuralspot_edge.models.efficientnet import (
- EfficientNetV2,
- EfficientNetParams,
- MBConvParams,
-)
-from rich.console import Console
-
-from ...defines import ModelArchitecture
-from ...models import ModelFactory
-
-console = Console()
-
-
-def create_model(inputs: keras.KerasTensor, num_classes: int, architecture: ModelArchitecture | None) -> keras.Model:
- """Generate model or use default
-
- Args:
- inputs (keras.KerasTensor): Model inputs
- num_classes (int): Number of classes
- architecture (ModelArchitecture|None): Model
-
- Returns:
- keras.Model: Model
- """
- if architecture:
- return ModelFactory.get(architecture.name)(
- x=inputs,
- params=architecture.params,
- num_classes=num_classes,
- )
-
- return default_model(inputs=inputs, num_classes=num_classes)
-
-
-def default_model(
- inputs: keras.KerasTensor,
- num_classes: int,
-) -> keras.Model:
- """Reference model
-
- Args:
- inputs (keras.KerasTensor): Model inputs
- num_classes (int): Number of classes
-
- Returns:
- keras.Model: Model
- """
-
- blocks = [
- MBConvParams(
- filters=32,
- depth=2,
- ex_ratio=1,
- kernel_size=(1, 3),
- strides=(1, 2),
- se_ratio=2,
- ),
- MBConvParams(
- filters=48,
- depth=1,
- ex_ratio=1,
- kernel_size=(1, 3),
- strides=(1, 2),
- se_ratio=4,
- ),
- MBConvParams(
- filters=64,
- depth=2,
- ex_ratio=1,
- kernel_size=(1, 3),
- strides=(1, 2),
- se_ratio=4,
- ),
- MBConvParams(
- filters=80,
- depth=1,
- ex_ratio=1,
- kernel_size=(1, 3),
- strides=(1, 2),
- se_ratio=4,
- ),
- ]
- return EfficientNetV2(
- inputs,
- params=EfficientNetParams(
- input_filters=24,
- input_kernel_size=(1, 3),
- input_strides=(1, 2),
- blocks=blocks,
- output_filters=0,
- include_top=True,
- dropout=0.0,
- drop_connect_rate=0.0,
- ),
- num_classes=num_classes,
- )
diff --git a/heartkit/tasks/foundation/__init__.py b/heartkit/tasks/foundation/__init__.py
index 83d16b59..233e1b4b 100644
--- a/heartkit/tasks/foundation/__init__.py
+++ b/heartkit/tasks/foundation/__init__.py
@@ -1,6 +1,7 @@
-from ...defines import HKDemoParams, HKExportParams, HKTestParams, HKTrainParams
+from ...defines import HKTaskParams
from ..task import HKTask
from . import datasets
+from .datasets import FoundationTaskFactory
from .demo import demo
from .evaluate import evaluate
from .export import export
@@ -11,17 +12,17 @@ class FoundationTask(HKTask):
"""HeartKit Foundation Task"""
@staticmethod
- def train(params: HKTrainParams):
+ def train(params: HKTaskParams):
train(params)
@staticmethod
- def evaluate(params: HKTestParams):
+ def evaluate(params: HKTaskParams):
evaluate(params)
@staticmethod
- def export(params: HKExportParams):
+ def export(params: HKTaskParams):
export(params)
@staticmethod
- def demo(params: HKDemoParams):
+ def demo(params: HKTaskParams):
demo(params)
diff --git a/heartkit/tasks/foundation/dataloaders/__init__.py b/heartkit/tasks/foundation/dataloaders/__init__.py
index 80b29d60..1f10c830 100644
--- a/heartkit/tasks/foundation/dataloaders/__init__.py
+++ b/heartkit/tasks/foundation/dataloaders/__init__.py
@@ -1,2 +1,10 @@
-from .lsad import lsad_data_generator
-from .ptbxl import ptbxl_data_generator
+import neuralspot_edge as nse
+
+from ....datasets import HKDataloader
+
+from .lsad import LsadDataloader
+from .ptbxl import PtbxlDataloader
+
+FoundationTaskFactory = nse.utils.create_factory(factory="FoundationTaskFactory", type=HKDataloader)
+FoundationTaskFactory.register("lsad", LsadDataloader)
+FoundationTaskFactory.register("ptbxl", PtbxlDataloader)
diff --git a/heartkit/tasks/foundation/dataloaders/lsad.py b/heartkit/tasks/foundation/dataloaders/lsad.py
index 5f5ec85d..0822ecbb 100644
--- a/heartkit/tasks/foundation/dataloaders/lsad.py
+++ b/heartkit/tasks/foundation/dataloaders/lsad.py
@@ -4,55 +4,60 @@
import numpy as np
import numpy.typing as npt
import physiokit as pk
-
-from ....datasets import LsadDataset, PatientGenerator
-
-
-def lsad_data_generator(
- patient_generator: PatientGenerator,
- ds: LsadDataset,
- frame_size: int,
- samples_per_patient: int | list[int] = 1,
- target_rate: int | None = None,
-) -> Generator[tuple[npt.NDArray, npt.NDArray], None, None]:
- """Generate frames using patient generator.
-
- Args:
- patient_generator (PatientGenerator): Patient Generator
- ds: LsadDataset
- frame_size (int): Frame size
- samples_per_patient (int | list[int], optional): # samples per patient. Defaults to 1.
- target_rate (int|None, optional): Target rate. Defaults to None.
-
- Returns:
- Generator[tuple[npt.NDArray, npt.NDArray], None, None]: Sample generator
-
- """
- input_size = int(np.round((ds.sampling_rate / target_rate) * frame_size))
- data_cache = {}
- for pt in patient_generator:
- if pt not in data_cache:
- with ds.patient_data(pt) as h5:
- data_cache[pt] = h5["data"][:]
- data = data_cache[pt]
- # with ds.patient_data(pt) as h5:
- # data = h5["data"][:]
-
- for _ in range(samples_per_patient):
- leads = random.sample(ds.leads, k=2)
- lead_p1 = leads[0]
- lead_p2 = leads[1]
- start_p1 = np.random.randint(0, data.shape[1] - input_size)
- start_p2 = np.random.randint(0, data.shape[1] - input_size)
- # start_p2 = start_p1
-
- x1 = np.nan_to_num(data[lead_p1, start_p1 : start_p1 + input_size].squeeze()).astype(np.float32)
- x2 = np.nan_to_num(data[lead_p2, start_p2 : start_p2 + input_size].squeeze()).astype(np.float32)
-
- if ds.sampling_rate != target_rate:
- x1 = pk.signal.resample_signal(x1, ds.sampling_rate, target_rate, axis=0)
- x2 = pk.signal.resample_signal(x2, ds.sampling_rate, target_rate, axis=0)
- # END IF
- yield x1, x2
+import neuralspot_edge as nse
+
+from ....datasets import HKDataloader, LsadDataset
+
+
+class LsadDataloader(HKDataloader):
+ def __init__(self, ds: LsadDataset, **kwargs):
+ """Lsad Dataloader for training foundation tasks
+
+ Args:
+ ds (LsadDataset): LsadDataset
+ """
+ super().__init__(ds=ds, **kwargs)
+
+ def patient_data_generator(
+ self,
+ patient_id: int,
+ samples_per_patient: list[int],
+ ):
+ input_size = int(np.ceil((self.ds.sampling_rate / self.sampling_rate) * self.frame_size))
+
+ with self.ds.patient_data(patient_id) as pt:
+ data = pt["data"][:]
+
+ for _ in range(samples_per_patient):
+ leads = random.sample(self.ds.leads, k=2)
+ lead_p1 = leads[0]
+ lead_p2 = leads[1]
+ start_p1 = np.random.randint(0, data.shape[1] - input_size)
+ start_p2 = np.random.randint(0, data.shape[1] - input_size)
+ # start_p2 = start_p1
+
+ x1 = np.nan_to_num(data[lead_p1, start_p1 : start_p1 + input_size].squeeze()).astype(np.float32)
+ x2 = np.nan_to_num(data[lead_p2, start_p2 : start_p2 + input_size].squeeze()).astype(np.float32)
+
+ if self.ds.sampling_rate != self.sampling_rate:
+ x1 = pk.signal.resample_signal(x1, self.ds.sampling_rate, self.sampling_rate, axis=0)
+ x2 = pk.signal.resample_signal(x2, self.ds.sampling_rate, self.sampling_rate, axis=0)
+ x1 = x1[: self.frame_size]
+ x2 = x2[: self.frame_size]
+ # END IF
+ x1 = np.reshape(x1, (-1, 1))
+ x2 = np.reshape(x2, (-1, 1))
+ yield x1, x2
+ # END FOR
+
+ def data_generator(
+ self,
+ patient_ids: list[int],
+ samples_per_patient: int | list[int],
+ shuffle: bool = False,
+ ) -> Generator[tuple[npt.NDArray, npt.NDArray], None, None]:
+ for pt_id in nse.utils.uniform_id_generator(patient_ids, shuffle=shuffle):
+ for x1, x2 in self.patient_data_generator(pt_id, samples_per_patient):
+ yield x1, x2
+ # END FOR
# END FOR
- # END FOR
diff --git a/heartkit/tasks/foundation/dataloaders/ptbxl.py b/heartkit/tasks/foundation/dataloaders/ptbxl.py
index 35a93c9d..9a2b3beb 100644
--- a/heartkit/tasks/foundation/dataloaders/ptbxl.py
+++ b/heartkit/tasks/foundation/dataloaders/ptbxl.py
@@ -4,55 +4,55 @@
import numpy as np
import numpy.typing as npt
import physiokit as pk
-
-from ....datasets import PatientGenerator, PtbxlDataset
-
-
-def ptbxl_data_generator(
- patient_generator: PatientGenerator,
- ds: PtbxlDataset,
- frame_size: int,
- samples_per_patient: int | list[int] = 1,
- target_rate: int | None = None,
-) -> Generator[tuple[npt.NDArray, npt.NDArray], None, None]:
- """Generate frames using patient generator.
-
- Args:
- patient_generator (PatientGenerator): Patient Generator
- ds: PtbxlDataset
- frame_size (int): Frame size
- samples_per_patient (int | list[int], optional): # samples per patient. Defaults to 1.
- target_rate (int|None, optional): Target rate. Defaults to None.
-
- Returns:
- Generator[tuple[npt.NDArray, npt.NDArray], None, None]: Sample generator
-
- """
- input_size = int(np.round((ds.sampling_rate / target_rate) * frame_size))
- data_cache = {}
- for pt in patient_generator:
- if pt not in data_cache:
- with ds.patient_data(pt) as h5:
- data_cache[pt] = h5["data"][:]
- data = data_cache[pt]
- # with ds.patient_data(pt) as h5:
- # data = h5["data"][:]
-
- for _ in range(samples_per_patient):
- leads = random.sample(ds.leads, k=2)
- lead_p1 = leads[0]
- lead_p2 = leads[1]
- start_p1 = np.random.randint(0, data.shape[1] - input_size)
- start_p2 = np.random.randint(0, data.shape[1] - input_size)
- # start_p2 = start_p1
-
- x1 = np.nan_to_num(data[lead_p1, start_p1 : start_p1 + input_size].squeeze()).astype(np.float32)
- x2 = np.nan_to_num(data[lead_p2, start_p2 : start_p2 + input_size].squeeze()).astype(np.float32)
-
- if ds.sampling_rate != target_rate:
- x1 = pk.signal.resample_signal(x1, ds.sampling_rate, target_rate, axis=0)
- x2 = pk.signal.resample_signal(x2, ds.sampling_rate, target_rate, axis=0)
- # END IF
- yield x1, x2
+import neuralspot_edge as nse
+
+from ....datasets import HKDataloader, PtbxlDataset
+
+
+class PtbxlDataloader(HKDataloader):
+ def __init__(self, ds: PtbxlDataset, **kwargs):
+ super().__init__(ds=ds, **kwargs)
+
+ def patient_data_generator(
+ self,
+ patient_id: int,
+ samples_per_patient: list[int],
+ ):
+ input_size = int(np.ceil((self.ds.sampling_rate / self.sampling_rate) * self.frame_size))
+
+ with self.ds.patient_data(patient_id) as pt:
+ data = pt["data"][:]
+
+ for _ in range(samples_per_patient):
+ leads = random.sample(self.ds.leads, k=2)
+ lead_p1 = leads[0]
+ lead_p2 = leads[1]
+ start_p1 = np.random.randint(0, data.shape[1] - input_size)
+ start_p2 = np.random.randint(0, data.shape[1] - input_size)
+ # start_p2 = start_p1
+
+ x1 = np.nan_to_num(data[lead_p1, start_p1 : start_p1 + input_size].squeeze()).astype(np.float32)
+ x2 = np.nan_to_num(data[lead_p2, start_p2 : start_p2 + input_size].squeeze()).astype(np.float32)
+
+ if self.ds.sampling_rate != self.sampling_rate:
+ x1 = pk.signal.resample_signal(x1, self.ds.sampling_rate, self.sampling_rate, axis=0)
+ x2 = pk.signal.resample_signal(x2, self.ds.sampling_rate, self.sampling_rate, axis=0)
+ x1 = x1[: self.frame_size]
+ x2 = x2[: self.frame_size]
+ # END IF
+ x1 = np.reshape(x1, (-1, 1))
+ x2 = np.reshape(x2, (-1, 1))
+ yield x1, x2
+ # END FOR
+
+ def data_generator(
+ self,
+ patient_ids: list[int],
+ samples_per_patient: int | list[int],
+ shuffle: bool = False,
+ ) -> Generator[tuple[npt.NDArray, npt.NDArray], None, None]:
+ for pt_id in nse.utils.uniform_id_generator(patient_ids, shuffle=shuffle):
+ for x1, x2 in self.patient_data_generator(pt_id, samples_per_patient):
+ yield x1, x2
+ # END FOR
# END FOR
- # END FOR
diff --git a/heartkit/tasks/foundation/datasets.py b/heartkit/tasks/foundation/datasets.py
index a7655eb0..260fc8fe 100644
--- a/heartkit/tasks/foundation/datasets.py
+++ b/heartkit/tasks/foundation/datasets.py
@@ -1,301 +1,149 @@
-import functools
-import logging
-from pathlib import Path
-
import numpy as np
-import numpy.typing as npt
import tensorflow as tf
+import neuralspot_edge as nse
-from ...datasets import (
- HKDataset,
- augment_pipeline,
- preprocess_pipeline,
- uniform_id_generator,
-)
-from ...datasets.dataloader import test_dataloader, train_val_dataloader
-from ...defines import (
- AugmentationParams,
- HKExportParams,
- HKTestParams,
- HKTrainParams,
- PreprocessParams,
-)
-from ...utils import resolve_template_path
-from .dataloaders import lsad_data_generator, ptbxl_data_generator
-
-logger = logging.getLogger(__name__)
-
-
-def preprocess(x: npt.NDArray, preprocesses: list[PreprocessParams], sample_rate: float) -> npt.NDArray:
- """Preprocess data pipeline
-
- Args:
- x (npt.NDArray): Input data
- preprocesses (list[PreprocessParams]): Preprocess parameters
- sample_rate (float): Sample rate
-
- Returns:
- npt.NDArray: Preprocessed data
- """
- return preprocess_pipeline(x, preprocesses=preprocesses, sample_rate=sample_rate)
-
-
-def augment(x: npt.NDArray, augmentations: list[AugmentationParams], sample_rate: float) -> npt.NDArray:
- """Augment data pipeline
-
- Args:
- x (npt.NDArray): Input data
- augmentations (list[AugmentationParams]): Augmentation parameters
- sample_rate (float): Sample rate
-
- Returns:
- npt.NDArray: Augmented data
- """
-
- return augment_pipeline(x=x, augmentations=augmentations, sample_rate=sample_rate)
-
-
-def prepare(
- x_y: tuple[npt.NDArray, npt.NDArray],
- sample_rate: float,
- preprocesses: list[PreprocessParams],
- augmentations: list[AugmentationParams],
- spec: tuple[tf.TensorSpec, tf.TensorSpec],
- num_classes: int,
-) -> tuple[npt.NDArray, npt.NDArray]:
- """Prepare dataset
-
- Args:
- x_y (tuple[npt.NDArray, npt.NDArray]): Input data
- sample_rate (float): Sampling rate
- preprocesses (list[PreprocessParams]): Preprocessing pipeline
- augmentations (list[AugmentationParams]): Augmentation pipeline
- spec (tuple[tf.TensorSpec, tf.TensorSpec]): Spec
- num_classes (int): Number of classes
-
- Returns:
- tuple[npt.NDArray, npt.NDArray]: Prepared data
- """
- x, y = x_y[0].copy(), x_y[1].copy()
-
- if augmentations:
- x = augment(x, augmentations, sample_rate)
- y = augment(y, augmentations, sample_rate)
- # END IF
-
- if preprocesses:
- x = preprocess(x, preprocesses, sample_rate)
- y = preprocess(y, preprocesses, sample_rate)
- # END IF
-
- x = x.reshape(spec[0].shape)
- y = y.reshape(spec[0].shape)
-
- return x, y
+from ...datasets import HKDataset, create_augmentation_pipeline
+from ...datasets.dataloader import HKDataloader
+from ...defines import HKTaskParams, NamedParams
+from .dataloaders import FoundationTaskFactory
-def get_data_generator(ds: HKDataset, frame_size: int, samples_per_patient: int, target_rate: int):
- """Get task data generator for dataset
+logger = nse.utils.setup_logger(__name__)
- Args:
- ds (HKDataset): Dataset
- frame_size (int): Frame size
- samples_per_patient (int): Samples per patient
- target_rate (int): Target rate
- Returns:
- callable: Data generator
- """
- match ds.name:
- case "ptbxl":
- data_generator = ptbxl_data_generator
- case "lsad":
- data_generator = lsad_data_generator
- case _:
- raise ValueError(f"Dataset {ds.name} not supported")
- # END MATCH
- return functools.partial(
- data_generator,
- ds=ds,
- frame_size=frame_size,
- samples_per_patient=samples_per_patient,
- target_rate=target_rate,
- )
-
-
-def resolve_ds_cache_path(fpath: Path | None, ds: HKDataset, task: str, frame_size: int, sample_rate: int):
- """Resolve dataset cache path
-
- Args:
- fpath (Path|None): File path
- ds (HKDataset): Dataset
- task (str): Task
- frame_size (int): Frame size
- sample_rate (int): Sampling rate
-
- Returns:
- Path|None: Resolved path
- """
- if not fpath:
- return None
- return resolve_template_path(
- fpath=fpath,
- dataset=ds.name,
- task=task,
- frame_size=frame_size,
- sampling_rate=sample_rate,
+def create_data_pipeline(
+ ds: tf.data.Dataset,
+ sampling_rate: int,
+ batch_size: int,
+ buffer_size: int | None = None,
+ preprocesses: list[NamedParams] | None = None,
+ augmentations: list[NamedParams] | None = None,
+):
+ augmenter = create_augmentation_pipeline(augmentations + preprocesses, sampling_rate)
+ if buffer_size:
+ ds = ds.shuffle(
+ buffer_size=buffer_size,
+ reshuffle_each_iteration=True,
+ )
+ if batch_size:
+ ds = ds.batch(
+ batch_size=batch_size,
+ drop_remainder=True,
+ num_parallel_calls=tf.data.AUTOTUNE,
+ )
+ ds = ds.map(
+ lambda x1, x2: {
+ nse.trainers.SimCLRTrainer.SAMPLES: x1,
+ nse.trainers.SimCLRTrainer.AUG_SAMPLES_0: augmenter(x1),
+ nse.trainers.SimCLRTrainer.AUG_SAMPLES_1: augmenter(x2),
+ },
+ num_parallel_calls=tf.data.AUTOTUNE,
)
+ return ds.prefetch(tf.data.AUTOTUNE)
def load_train_datasets(
datasets: list[HKDataset],
- params: HKTrainParams,
- ds_spec: tuple[tf.TensorSpec, tf.TensorSpec],
+ params: HKTaskParams,
) -> tuple[tf.data.Dataset, tf.data.Dataset]:
- """Load training and validation datasets
-
- Args:
- datasets (list[HKDataset]): Datasets
- params (HKTrainParams): Training parameters
- ds_spec (tuple[tf.TensorSpec, tf.TensorSpec]): TensorSpec
-
- Returns:
- tuple[tf.data.Dataset, tf.data.Dataset]: Train and validation datasets
- """
-
- id_generator = functools.partial(uniform_id_generator, repeat=True)
- train_prepare = functools.partial(
- prepare,
- sample_rate=params.sampling_rate,
- preprocesses=params.preprocesses,
- augmentations=params.augmentations,
- spec=ds_spec,
- num_classes=params.num_classes,
- )
-
train_datasets = []
val_datasets = []
for ds in datasets:
- val_file = resolve_ds_cache_path(
- params.val_file,
- ds=ds,
- task="foundation",
- frame_size=params.frame_size,
- sample_rate=params.sampling_rate,
- )
- data_generator = get_data_generator(
+ dataloader: HKDataloader = FoundationTaskFactory.get(ds.name)(
ds=ds,
frame_size=params.frame_size,
- samples_per_patient=params.samples_per_patient,
- target_rate=params.sampling_rate,
+ sampling_rate=params.sampling_rate,
+ label_map=params.class_map,
)
-
- train_ds, val_ds = train_val_dataloader(
- ds=ds,
- spec=ds_spec,
- data_generator=data_generator,
- id_generator=id_generator,
+ train_patients, val_patients = dataloader.split_train_val_patients(
train_patients=params.train_patients,
val_patients=params.val_patients,
- val_pt_samples=params.val_samples_per_patient,
- val_file=val_file,
- val_size=params.val_size,
- label_map=None,
- label_type=None,
- preprocess=train_prepare,
- num_workers=params.data_parallelism,
+ )
+
+ train_ds = dataloader.create_dataloader(
+ patient_ids=train_patients, samples_per_patient=params.samples_per_patient, shuffle=True
+ )
+
+ val_ds = dataloader.create_dataloader(
+ patient_ids=val_patients, samples_per_patient=params.val_samples_per_patient, shuffle=False
)
train_datasets.append(train_ds)
val_datasets.append(val_ds)
# END FOR
- ds_weights = np.array([d.weight for d in params.datasets])
- ds_weights = ds_weights / ds_weights.sum()
+ ds_weights = None
+ if params.dataset_weights:
+ ds_weights = np.array(params.dataset_weights)
+ ds_weights = ds_weights / ds_weights.sum()
train_ds = tf.data.Dataset.sample_from_datasets(train_datasets, weights=ds_weights)
val_ds = tf.data.Dataset.sample_from_datasets(val_datasets, weights=ds_weights)
# Shuffle and batch datasets for training
- train_ds = (
- train_ds.shuffle(
- buffer_size=params.buffer_size,
- reshuffle_each_iteration=True,
- )
- .batch(
- batch_size=params.batch_size,
- drop_remainder=False,
- num_parallel_calls=tf.data.AUTOTUNE,
- )
- .prefetch(buffer_size=tf.data.AUTOTUNE)
+ train_ds = create_data_pipeline(
+ ds=train_ds,
+ sampling_rate=params.sampling_rate,
+ batch_size=params.batch_size,
+ buffer_size=params.buffer_size,
+ preprocesses=params.preprocesses,
+ augmentations=params.augmentations,
)
- val_ds = val_ds.batch(
+
+ val_ds = create_data_pipeline(
+ ds=val_ds,
+ sampling_rate=params.sampling_rate,
batch_size=params.batch_size,
- drop_remainder=True,
- num_parallel_calls=tf.data.AUTOTUNE,
+ preprocesses=params.preprocesses,
+ augmentations=params.augmentations,
)
+
+ # If given fixed val size or steps, then capture and cache
+ val_steps_per_epoch = params.val_size // params.batch_size if params.val_size else params.val_steps_per_epoch
+ if val_steps_per_epoch:
+ logger.info(f"Validation steps per epoch: {val_steps_per_epoch}")
+ val_ds = val_ds.take(val_steps_per_epoch).cache()
+
return train_ds, val_ds
def load_test_dataset(
datasets: list[HKDataset],
- params: HKTestParams | HKExportParams,
- ds_spec: tuple[tf.TensorSpec, tf.TensorSpec],
+ params: HKTaskParams,
) -> tf.data.Dataset:
- """Load test dataset
-
- Args:
- datasets (list[HKDataset]): Datasets
- params (HKTestParams|HKExportParams): Test parameters
- ds_spec (tuple[tf.TensorSpec, tf.TensorSpec]): TensorSpec
-
- Returns:
- tf.data.Dataset: Test dataset
- """
-
- id_generator = functools.partial(uniform_id_generator, repeat=True)
- test_prepare = functools.partial(
- prepare,
- preprocesses=params.preprocesses,
- augmentations=params.augmentations,
- spec=ds_spec,
- num_classes=params.num_classes,
- )
-
test_datasets = []
for ds in datasets:
- test_file = resolve_ds_cache_path(
- fpath=params.test_file,
+ dataloader: HKDataloader = FoundationTaskFactory.get(ds.name)(
ds=ds,
- task="foundation",
frame_size=params.frame_size,
- sample_rate=params.sampling_rate,
+ sampling_rate=params.sampling_rate,
+ label_map=params.class_map,
)
- data_generator = get_data_generator(
- ds=ds,
- frame_size=params.frame_size,
+ test_patients = dataloader.test_patient_ids(params.test_patients)
+ test_ds = dataloader.create_dataloader(
+ patient_ids=test_patients,
samples_per_patient=params.test_samples_per_patient,
- target_rate=params.sampling_rate,
- )
-
- test_ds = test_dataloader(
- ds=ds,
- spec=ds_spec,
- data_generator=data_generator,
- id_generator=id_generator,
- test_patients=params.test_patients,
- test_file=test_file,
- label_map=None,
- label_type=None,
- preprocess=test_prepare,
- num_workers=params.data_parallelism,
+ shuffle=False,
)
test_datasets.append(test_ds)
# END FOR
- ds_weights = np.array([d.weight for d in params.datasets])
- ds_weights = ds_weights / ds_weights.sum()
+ ds_weights = None
+ if params.dataset_weights:
+ ds_weights = np.array(params.dataset_weights)
+ ds_weights = ds_weights / ds_weights.sum()
test_ds = tf.data.Dataset.sample_from_datasets(test_datasets, weights=ds_weights)
- # END WITH
+ test_ds = create_data_pipeline(
+ ds=test_ds,
+ sampling_rate=params.sampling_rate,
+ batch_size=params.batch_size,
+ preprocesses=params.preprocesses,
+ augmentations=params.augmentations,
+ )
+
+ if params.test_size:
+ batch_size = getattr(params, "batch_size", 1)
+ test_ds = test_ds.take(params.test_size // batch_size).cache()
+
return test_ds
diff --git a/heartkit/tasks/foundation/demo.py b/heartkit/tasks/foundation/demo.py
index 3272cab8..cc9f509a 100644
--- a/heartkit/tasks/foundation/demo.py
+++ b/heartkit/tasks/foundation/demo.py
@@ -7,24 +7,22 @@
from plotly.subplots import make_subplots
from sklearn.manifold import TSNE
from tqdm import tqdm
+import neuralspot_edge as nse
-from ...datasets.utils import uniform_id_generator
-from ...defines import HKDemoParams
+from ...defines import HKTaskParams
from ...rpc import BackendFactory
-from ...utils import setup_logger
-from ..utils import load_datasets
-from .datasets import preprocess
+from ...datasets import DatasetFactory
-logger = setup_logger(__name__)
-
-def demo(params: HKDemoParams):
+def demo(params: HKTaskParams):
"""Run demo for model
Args:
- params (HKDemoParams): Demo parameters
+ params (HKTaskParams): Demo parameters
"""
+ logger = nse.utils.setup_logger(__name__, level=params.verbose)
+
bg_color = "rgba(38,42,50,1.0)"
# primary_color = "#11acd5"
# secondary_color = "#ce6cff"
@@ -35,10 +33,10 @@ def demo(params: HKDemoParams):
TGT_LEN = 20
# Load backend inference engine
- runner = BackendFactory.create(params.backend, params=params)
+ runner = BackendFactory.get(params.backend)(params=params)
# load datasets and randomly select one
- datasets = load_datasets(datasets=params.datasets)
+ datasets = [DatasetFactory.get(ds.name)(**ds.params) for ds in params.datasets]
ds = random.choice(datasets)
patients: npt.NDArray = ds.get_test_patient_ids()
@@ -49,7 +47,7 @@ def demo(params: HKDemoParams):
# For each patient, generate TGT_LEN samples
for i, patient in enumerate(patients):
ds_gen = ds.signal_generator(
- patient_generator=uniform_id_generator([patient], repeat=False),
+ patient_generator=nse.utils.uniform_id_generator([patient], repeat=False),
frame_size=params.frame_size,
samples_per_patient=TGT_LEN,
target_rate=params.sampling_rate,
@@ -65,7 +63,7 @@ def demo(params: HKDemoParams):
logger.debug("Running inference")
x_p = []
for i in tqdm(range(0, len(x)), desc="Inference"):
- x[i] = preprocess(x[i], sample_rate=params.sampling_rate, preprocesses=params.preprocesses)
+ # x[i] = preprocess(x[i], sample_rate=params.sampling_rate, preprocesses=params.preprocesses)
xx = x[i].copy()
xx = xx.reshape(feat_shape)
runner.set_inputs(xx)
diff --git a/heartkit/tasks/foundation/evaluate.py b/heartkit/tasks/foundation/evaluate.py
index 67dbab41..177eb7cc 100644
--- a/heartkit/tasks/foundation/evaluate.py
+++ b/heartkit/tasks/foundation/evaluate.py
@@ -1,15 +1,71 @@
-from ...defines import HKTestParams
-from ...utils import setup_logger
+import os
-logger = setup_logger(__name__)
+import keras
+import numpy as np
+import matplotlib.pyplot as plt
+import neuralspot_edge as nse
+from sklearn.manifold import TSNE
+from ...defines import HKTaskParams
+from ...datasets import DatasetFactory
+from .datasets import load_test_dataset
+from ...utils import setup_plotting
-def evaluate(params: HKTestParams):
+
+def evaluate(params: HKTaskParams):
"""Evaluate model
Args:
- params (HKTestParams): Evaluation parameters
+ params (HKTaskParams): Evaluation parameters
"""
- # Would need encoder along with either projector or classifier to evaluate
+ os.makedirs(params.job_dir, exist_ok=True)
+ logger = nse.utils.setup_logger(__name__, level=params.verbose, file_path=params.job_dir / "test.log")
+ logger.debug(f"Creating working directory in {params.job_dir}")
+
+ params.seed = nse.utils.set_random_seed(params.seed)
+ logger.debug(f"Random seed {params.seed}")
+
+ datasets = [DatasetFactory.get(ds.name)(**ds.params) for ds in params.datasets]
+
+ # Grab sets of augmented samples
+ test_ds = load_test_dataset(datasets=datasets, params=params)
+ test_x1, test_x2 = [], []
+ for inputs in test_ds.as_numpy_iterator():
+ test_x1.append(inputs[nse.trainers.SimCLRTrainer.AUG_SAMPLES_0])
+ test_x2.append(inputs[nse.trainers.SimCLRTrainer.AUG_SAMPLES_1])
+ test_x1 = np.concatenate(test_x1)
+ test_x2 = np.concatenate(test_x2)
+
+ logger.debug("Loading model")
+ model = nse.models.load_model(params.model_file)
+ flops = nse.metrics.flops.get_flops(model, batch_size=1, fpath=params.job_dir / "model_flops.log")
+
+ model.summary(print_fn=logger.debug)
+ logger.debug(f"Model requires {flops/1e6:0.2f} MFLOPS")
+
+ logger.debug("Performing inference")
+ test_y1 = model.predict(test_x1)
+ test_y2 = model.predict(test_x2)
+
+ metrics = [
+ keras.metrics.CosineSimilarity(name="cos"),
+ keras.metrics.MeanSquaredError(name="mse"),
+ ]
+
+ setup_plotting()
+
+ tf_rst = nse.metrics.compute_metrics(metrics, test_y1, test_y2)
+ logger.info("[TEST SET] " + ", ".join([f"{k.upper()}={v:.2%}" for k, v in tf_rst.items()]))
+
+ # Compute t-SNE
+ logger.debug("Computing t-SNE")
+ tsne = TSNE(n_components=2, random_state=0, n_iter=1000, perplexity=75)
+ x_tsne = tsne.fit_transform(test_y1)
- return
+ # Plot t-SNE in matplotlib
+ fig, ax = plt.subplots(1, 1, figsize=(9, 9))
+ ax.scatter(x_tsne[:, 0], x_tsne[:, 1], c=x_tsne[:, 0] - x_tsne[:, 1], cmap="viridis")
+ fig.suptitle("HK Foundation: t-SNE")
+ ax.set_xlabel("Component 1")
+ ax.set_ylabel("Component 2")
+ fig.savefig(params.job_dir / "tsne.png")
diff --git a/heartkit/tasks/foundation/export.py b/heartkit/tasks/foundation/export.py
index ad58cb46..2c0640a2 100644
--- a/heartkit/tasks/foundation/export.py
+++ b/heartkit/tasks/foundation/export.py
@@ -1,53 +1,39 @@
-import logging
import os
import keras
import numpy as np
-import tensorflow as tf
-
import neuralspot_edge as nse
-from ...defines import HKExportParams
-from ...utils import setup_logger
-from ..utils import load_datasets
-from .datasets import load_test_dataset
-logger = setup_logger(__name__)
+from ...defines import HKTaskParams
+from ...datasets import DatasetFactory
+from .datasets import load_test_dataset
-def export(params: HKExportParams):
+def export(params: HKTaskParams):
"""Export model
Args:
- params (HKExportParams): Deployment parameters
+ params (HKTaskParams): Deployment parameters
"""
-
os.makedirs(params.job_dir, exist_ok=True)
+ logger = nse.utils.setup_logger(__name__, level=params.verbose, file_path=params.job_dir / "export.log")
logger.debug(f"Creating working directory in {params.job_dir}")
- handler = logging.FileHandler(params.job_dir / "export.log", mode="w")
- handler.setLevel(logging.INFO)
- logger.addHandler(handler)
-
- feat_shape = (params.frame_size, 1)
-
tfl_model_path = params.job_dir / "model.tflite"
tflm_model_path = params.job_dir / "model_buffer.h"
- ds_spec = (
- tf.TensorSpec(shape=feat_shape, dtype="float32"),
- tf.TensorSpec(shape=feat_shape, dtype="float32"),
- )
+ feat_shape = (params.frame_size, 1)
- datasets = load_datasets(datasets=params.datasets)
+ datasets = [DatasetFactory.get(ds.name)(**ds.params) for ds in params.datasets]
- test_ds = load_test_dataset(datasets=datasets, params=params, ds_spec=ds_spec)
- test_x, _ = next(test_ds.batch(params.test_size).as_numpy_iterator())
+ test_ds = load_test_dataset(datasets=datasets, params=params)
+ test_x = np.concatenate([x[nse.trainers.SimCLRTrainer.SAMPLES] for x in test_ds.as_numpy_iterator()])
# Load model and set fixed batch size of 1
logger.debug("Loading trained model")
model = nse.models.load_model(params.model_file)
- inputs = keras.Input(shape=ds_spec[0].shape, batch_size=1, dtype=ds_spec[0].dtype)
+ inputs = keras.Input(shape=feat_shape, batch_size=1, dtype="float32")
model(inputs)
flops = nse.metrics.flops.get_flops(model, batch_size=1, fpath=params.job_dir / "model_flops.log")
@@ -56,6 +42,7 @@ def export(params: HKExportParams):
logger.debug(f"Converting model to TFLite (quantization={params.quantization.mode})")
converter = nse.converters.tflite.TfLiteKerasConverter(model=model)
+
tflite_content = converter.convert(
test_x=test_x,
quantization=params.quantization.format,
diff --git a/heartkit/tasks/foundation/train.py b/heartkit/tasks/foundation/train.py
index 50c169ba..205af0d5 100644
--- a/heartkit/tasks/foundation/train.py
+++ b/heartkit/tasks/foundation/train.py
@@ -1,124 +1,97 @@
-import logging
import os
import keras
-import tensorflow as tf
import wandb
+import numpy as np
from wandb.keras import WandbMetricsLogger, WandbModelCheckpoint
-
import neuralspot_edge as nse
-from ...defines import HKTrainParams
+
+from ...defines import HKTaskParams
from ...models import ModelFactory
-from ...utils import env_flag, set_random_seed, setup_logger
-from ..utils import load_datasets
+from ...datasets import DatasetFactory
from .datasets import load_train_datasets
-
-logger = setup_logger(__name__)
+from ...utils import setup_plotting, dark_theme
-def train(params: HKTrainParams):
+def train(params: HKTaskParams):
"""Train model
Args:
- params (HKTrainParams): Training parameters
+ params (HKTaskParams): Training parameters
"""
+ os.makedirs(params.job_dir, exist_ok=True)
+ logger = nse.utils.setup_logger(__name__, level=params.verbose, file_path=params.job_dir / "train.log")
+ logger.debug(f"Creating working directory in {params.job_dir}")
params.temperature = float(getattr(params, "temperature", 0.1))
- params.seed = set_random_seed(params.seed)
+ params.seed = nse.utils.set_random_seed(params.seed)
logger.debug(f"Random seed {params.seed}")
- os.makedirs(params.job_dir, exist_ok=True)
- logger.debug(f"Creating working directory in {params.job_dir}")
-
- handler = logging.FileHandler(params.job_dir / "train.log", mode="w")
- handler.setLevel(logging.INFO)
- logger.addHandler(handler)
-
with open(params.job_dir / "train_config.json", "w", encoding="utf-8") as fp:
fp.write(params.model_dump_json(indent=2))
- if env_flag("WANDB"):
- wandb.init(
- project=params.project,
- entity="ambiq",
- dir=params.job_dir,
- )
+ if nse.utils.env_flag("WANDB"):
+ wandb.init(project=params.project, entity="ambiq", dir=params.job_dir)
wandb.config.update(params.model_dump())
# END IF
- # Currently we return positive pairs w/o labels
feat_shape = (params.frame_size, 1)
- ds_spec = (
- tf.TensorSpec(shape=feat_shape, dtype="float32"),
- tf.TensorSpec(shape=feat_shape, dtype="float32"),
- )
-
- datasets = load_datasets(datasets=params.datasets)
- train_ds, val_ds = load_train_datasets(
- datasets=datasets,
- params=params,
- ds_spec=ds_spec,
- )
+ datasets = [DatasetFactory.get(ds.name)(**ds.params) for ds in params.datasets]
- projection_width = params.num_classes
+ train_ds, val_ds = load_train_datasets(datasets=datasets, params=params)
+ # Create encoder
encoder_input = keras.Input(shape=feat_shape, dtype="float32")
-
- # Encoder
encoder = ModelFactory.get(params.architecture.name)(
x=encoder_input,
params=params.architecture.params,
num_classes=None,
)
- encoder_output = encoder(encoder_input)
flops = nse.metrics.flops.get_flops(encoder, batch_size=1, fpath=params.job_dir / "encoder_flops.log")
encoder.summary(print_fn=logger.info)
logger.debug(f"Encoder requires {flops/1e6:0.2f} MFLOPS")
- # Projector
- projector_input = encoder_output
- projector_output = keras.layers.Dense(projection_width, activation="relu6")(projector_input)
- projector_output = keras.layers.Dense(projection_width)(projector_output)
- projector = keras.Model(inputs=projector_input, outputs=projector_output, name="projector")
- flops = nse.metrics.flops.get_flops(projector, batch_size=1, fpath=params.job_dir / "projector_flops.log")
- projector.summary(print_fn=logger.info)
- logger.debug(f"Projector requires {flops/1e6:0.2f} MFLOPS")
+ # Create projector
+ # encoder_output = encoder(encoder_input)
+ # projection_width = params.num_classes
+ # projector_input = encoder_output
+ # projector_output = keras.layers.Dense(projection_width, activation="relu6")(projector_input)
+ # projector_output = keras.layers.Dense(projection_width)(projector_output)
+ # projector = keras.Model(inputs=projector_input, outputs=projector_output, name="projector")
+ # flops = nse.metrics.flops.get_flops(projector, batch_size=1, fpath=params.job_dir / "projector_flops.log")
+ # projector.summary(print_fn=logger.info)
+ # logger.debug(f"Projector requires {flops/1e6:0.2f} MFLOPS")
if params.model_file is None:
params.model_file = params.job_dir / "model.keras"
- model = nse.models.opimizers.simclr.SimCLR(
- contrastive_augmenter=lambda x: x,
+ model = nse.trainers.SimCLRTrainer(
encoder=encoder,
- projector=projector,
- # momentum_coeff=0.999,
- temperature=params.temperature,
- # queue_size=65536,
+ projector=None,
)
def get_scheduler():
- if params.lr_cycles > 1:
- return keras.optimizers.schedules.CosineDecayRestarts(
- initial_learning_rate=params.lr_rate,
- first_decay_steps=int(0.1 * params.steps_per_epoch * params.epochs),
- t_mul=1.65 / (0.1 * params.lr_cycles * (params.lr_cycles - 1)),
- m_mul=0.4,
- )
- return keras.optimizers.schedules.CosineDecay(
+ t_mul = 1
+ first_steps = (params.steps_per_epoch * params.epochs) / (np.power(params.lr_cycles, t_mul) - t_mul + 1)
+ scheduler = keras.optimizers.schedules.CosineDecayRestarts(
initial_learning_rate=params.lr_rate,
- decay_steps=params.steps_per_epoch * params.epochs,
+ first_decay_steps=np.ceil(first_steps),
+ t_mul=t_mul,
+ m_mul=0.5,
)
+ return scheduler
model.compile(
- contrastive_optimizer=keras.optimizers.Adam(get_scheduler()),
- probe_optimizer=keras.optimizers.Adam(get_scheduler()),
+ encoder_optimizer=keras.optimizers.Adam(get_scheduler()),
+ encoder_loss=nse.losses.simclr.SimCLRLoss(temperature=params.temperature),
+ encoder_metrics=[keras.metrics.MeanSquaredError(name="mse"), keras.metrics.CosineSimilarity(name="cos")],
)
ModelCheckpoint = keras.callbacks.ModelCheckpoint
- if env_flag("WANDB"):
+ if nse.utils.env_flag("WANDB"):
ModelCheckpoint = WandbModelCheckpoint
model_callbacks = [
keras.callbacks.EarlyStopping(
@@ -126,28 +99,29 @@ def get_scheduler():
patience=max(int(0.25 * params.epochs), 1),
mode="max" if params.val_metric == "f1" else "auto",
restore_best_weights=True,
+ verbose=params.verbose - 1,
),
ModelCheckpoint(
filepath=str(params.model_file),
monitor=f"val_{params.val_metric}",
save_best_only=True,
mode="max" if params.val_metric == "f1" else "auto",
- verbose=1,
+ verbose=params.verbose - 1,
),
keras.callbacks.CSVLogger(params.job_dir / "history.csv"),
]
- if env_flag("TENSORBOARD"):
+ if nse.utils.env_flag("TENSORBOARD"):
model_callbacks.append(
keras.callbacks.TensorBoard(
log_dir=params.job_dir,
write_steps_per_second=True,
)
)
- if env_flag("WANDB"):
+ if nse.utils.env_flag("WANDB"):
model_callbacks.append(WandbMetricsLogger())
try:
- model.fit(
+ history = model.fit(
train_ds,
steps_per_epoch=params.steps_per_epoch,
verbose=2,
@@ -159,3 +133,18 @@ def get_scheduler():
logger.warning("Stopping training due to keyboard interrupt")
logger.debug(f"Model saved to {params.model_file}")
+
+ setup_plotting(dark_theme)
+ nse.plotting.plot_history_metrics(
+ history.history,
+ metrics=["loss", "cos"],
+ save_path=params.job_dir / "history.png",
+ stack=True,
+ figsize=(9, 5),
+ )
+
+ metrics = model.evaluate(val_ds, verbose=2, return_dict=True)
+
+ logger.info(f"Loss: {metrics['loss']:.2f}")
+ logger.info(f"Mean Squared Error: {metrics['mse']:.2f}")
+ logger.info(f"Cosine Similarity: {metrics['cos']:.2%}")
diff --git a/heartkit/tasks/rhythm/__init__.py b/heartkit/tasks/rhythm/__init__.py
index 360fe409..9e6ef07d 100644
--- a/heartkit/tasks/rhythm/__init__.py
+++ b/heartkit/tasks/rhythm/__init__.py
@@ -1,4 +1,4 @@
-from ...defines import HKDemoParams, HKExportParams, HKTestParams, HKTrainParams
+from ...defines import HKTaskParams
from ..task import HKTask
from .defines import HKRhythm
from .demo import demo
@@ -18,17 +18,17 @@ def description() -> str:
)
@staticmethod
- def train(params: HKTrainParams):
+ def train(params: HKTaskParams):
train(params)
@staticmethod
- def evaluate(params: HKTestParams):
+ def evaluate(params: HKTaskParams):
evaluate(params)
@staticmethod
- def export(params: HKExportParams):
+ def export(params: HKTaskParams):
export(params)
@staticmethod
- def demo(params: HKDemoParams):
+ def demo(params: HKTaskParams):
demo(params)
diff --git a/heartkit/tasks/rhythm/dataloaders/__init__.py b/heartkit/tasks/rhythm/dataloaders/__init__.py
index ecb2836b..1feadddf 100644
--- a/heartkit/tasks/rhythm/dataloaders/__init__.py
+++ b/heartkit/tasks/rhythm/dataloaders/__init__.py
@@ -1,3 +1,14 @@
-from .icentia11k import icentia11k_data_generator, icentia11k_label_map
-from .lsad import lsad_data_generator, lsad_label_map
-from .ptbxl import ptbxl_data_generator, ptbxl_label_map
+import neuralspot_edge as nse
+
+from ....datasets import HKDataloader
+
+from .icentia11k import Icentia11kDataloader
+from .icentia_mini import IcentiaMiniDataloader
+from .ptbxl import PtbxlDataloader
+from .lsad import LsadDataloader
+
+RhythmDataloaderFactory = nse.utils.create_factory(factory="HKRhythmDataloaderFactory", type=HKDataloader)
+RhythmDataloaderFactory.register("icentia11k", Icentia11kDataloader)
+RhythmDataloaderFactory.register("icentia_mini", IcentiaMiniDataloader)
+RhythmDataloaderFactory.register("ptbxl", PtbxlDataloader)
+RhythmDataloaderFactory.register("lsad", LsadDataloader)
diff --git a/heartkit/tasks/rhythm/dataloaders/icentia11k.py b/heartkit/tasks/rhythm/dataloaders/icentia11k.py
index e27bc3e4..14b7cda3 100644
--- a/heartkit/tasks/rhythm/dataloaders/icentia11k.py
+++ b/heartkit/tasks/rhythm/dataloaders/icentia11k.py
@@ -4,9 +4,9 @@
import numpy as np
import numpy.typing as npt
import physiokit as pk
+import neuralspot_edge as nse
-from ....datasets.defines import PatientGenerator
-from ....datasets.icentia11k import IcentiaDataset, IcentiaRhythm
+from ....datasets import HKDataloader, IcentiaDataset, IcentiaRhythm
from ..defines import HKRhythm
IcentiaRhythmMap = {
@@ -18,64 +18,25 @@
}
-def icentia11k_label_map(
- label_map: dict[int, int] | None = None,
-) -> dict[int, int]:
- """Get label map
-
- Args:
- label_map (dict[int, int]|None): Label map
-
- Returns:
- dict[int, int]: Label map
- """
- return {k: label_map.get(v, -1) for (k, v) in IcentiaRhythmMap.items()}
-
-
-def icentia11k_data_generator(
- patient_generator: PatientGenerator,
- ds: IcentiaDataset,
- frame_size: int,
- samples_per_patient: int | list[int] = 1,
- target_rate: int | None = None,
- label_map: dict[int, int] | None = None,
-) -> Generator[tuple[npt.NDArray, int], None, None]:
- """Generate frames w/ rhythm labels (e.g. afib) using patient generator.
-
- Args:
- patient_generator (PatientGenerator): Patient Generator
- ds: IcentiaDataset
- frame_size (int): Frame size
- samples_per_patient (int | list[int], optional): # samples per patient. Defaults to 1.
- target_rate (int|None, optional): Target rate. Defaults to None.
- label_map (dict[int, int] | None, optional): Label map. Defaults to None.
-
- Returns:
- Generator[tuple[npt.NDArray, int], None, None]: Sample generator
- """
- if target_rate is None:
- target_rate = ds.sampling_rate
- # END IF
-
- # Target labels and mapping
- tgt_labels = sorted(list(set((lbl for lbl in label_map.values() if lbl != -1))))
- tgt_map = icentia11k_label_map(label_map=label_map)
- label_key = ds.label_key("rhythm")
- num_classes = len(tgt_labels)
-
- # If samples_per_patient is a list, then it must be the same length as num_classes
- if isinstance(samples_per_patient, Iterable):
- samples_per_tgt = samples_per_patient
- else:
- num_per_tgt = int(max(1, samples_per_patient / num_classes))
- samples_per_tgt = num_per_tgt * [num_classes]
- # END IF
-
- input_size = int(np.round((ds.sampling_rate / target_rate) * frame_size))
-
- # Group patient rhythms by type (segment, start, stop, delta)
- for pt in patient_generator:
- with ds.patient_data(pt) as segments:
+class Icentia11kDataloader(HKDataloader):
+ def __init__(self, ds: IcentiaDataset, **kwargs):
+ super().__init__(ds=ds, **kwargs)
+ # Update label map
+ if self.label_map:
+ self.label_map = {k: self.label_map[v] for (k, v) in IcentiaRhythmMap.items() if v in self.label_map}
+ # END DEF
+ self.label_type = "rhythm"
+ # PT: [label_idx, segment, start, end]
+ self._pts_rhythm_map: dict[int, list[npt.NDArray]] = {}
+
+ def _create_patient_rhythm_map(self, patient_id: int):
+ # Target labels and mapping
+ tgt_labels = sorted(set((self.label_map.values())))
+ label_key = self.ds.label_key(self.label_type)
+
+ input_size = int(np.ceil((self.ds.sampling_rate / self.sampling_rate) * self.frame_size))
+
+ with self.ds.patient_data(patient_id=patient_id) as segments:
# This maps segment index to segment key
seg_map: list[str] = list(segments.keys())
@@ -95,7 +56,7 @@ def icentia11k_data_generator(
xs, xe, xl = labels[0::2, 0], labels[1::2, 0], labels[0::2, 1]
# Map labels to target labels
- xl = np.vectorize(tgt_map.get, otypes=[int])(xl)
+ xl = np.vectorize(self.label_map.get, otypes=[int])(xl)
# Capture segment, start, and end for each target label
for tgt_idx, tgt_class in enumerate(tgt_labels):
@@ -104,7 +65,28 @@ def icentia11k_data_generator(
pt_tgt_seg_map[tgt_idx] += seg_vals.tolist()
# END FOR
# END FOR
+
pt_tgt_seg_map = [np.array(b) for b in pt_tgt_seg_map]
+ self._pts_rhythm_map[patient_id] = pt_tgt_seg_map
+
+ def patient_data_generator(
+ self,
+ patient_id: int,
+ samples_per_patient: list[int],
+ ):
+ # Target labels and mapping
+ tgt_labels = sorted(set(self.label_map.values()))
+
+ input_size = int(np.ceil((self.ds.sampling_rate / self.sampling_rate) * self.frame_size))
+
+ # Group patient rhythms by type (segment, start, stop, delta)
+
+ with self.ds.patient_data(patient_id=patient_id) as segments:
+ # This maps segment index to segment key
+ seg_map: list[str] = list(segments.keys())
+ if patient_id not in self._pts_rhythm_map:
+ self._create_patient_rhythm_map(patient_id)
+ pt_tgt_seg_map = self._pts_rhythm_map[patient_id]
# Grab target segments
seg_samples: list[tuple[int, int, int, int]] = []
@@ -115,7 +97,7 @@ def icentia11k_data_generator(
tgt_seg_indices: list[int] = random.choices(
np.arange(tgt_segments.shape[0]),
weights=tgt_segments[:, 2] - tgt_segments[:, 1],
- k=samples_per_tgt[tgt_idx],
+ k=samples_per_patient[tgt_idx],
)
for tgt_seg_idx in tgt_seg_indices:
seg_idx, rhy_start, rhy_end = tgt_segments[tgt_seg_idx]
@@ -128,12 +110,42 @@ def icentia11k_data_generator(
# Shuffle segments
random.shuffle(seg_samples)
- # Yield selected samples for patient
+ # Grab selected samples for patient
+ samples = []
for seg_idx, frame_start, frame_end, label in seg_samples:
x: npt.NDArray = segments[seg_map[seg_idx]]["data"][frame_start:frame_end].astype(np.float32)
- if ds.sampling_rate != target_rate:
- x = pk.signal.resample_signal(x, ds.sampling_rate, target_rate, axis=0)
- yield x, label
+ if self.ds.sampling_rate != self.sampling_rate:
+ x = pk.signal.resample_signal(x, self.ds.sampling_rate, self.sampling_rate, axis=0)
+ x = x[: self.frame_size] # truncate to frame size
+ x = np.reshape(x, (self.frame_size, 1))
+ samples.append((x, label))
# END FOR
# END WITH
- # END FOR
+
+ for x, y in samples:
+ yield x, y
+ # END FOR
+
+ def data_generator(
+ self,
+ patient_ids: list[int],
+ samples_per_patient: int | list[int],
+ shuffle: bool = False,
+ ) -> Generator[tuple[npt.NDArray, npt.NDArray], None, None]:
+ # Target labels and mapping
+ tgt_labels = sorted(set(self.label_map.values()))
+ num_classes = len(tgt_labels)
+
+ # If samples_per_patient is a list, then it must be the same length as nclasses
+ if isinstance(samples_per_patient, Iterable):
+ samples_per_tgt = samples_per_patient
+ else:
+ num_per_tgt = int(max(1, samples_per_patient / num_classes))
+ samples_per_tgt = num_per_tgt * [num_classes]
+
+ self._pts_beat_map = {}
+ for pt_id in nse.utils.uniform_id_generator(patient_ids, shuffle=shuffle):
+ for x, y in self.patient_data_generator(pt_id, samples_per_tgt):
+ yield x, y
+ # END FOR
+ # END FOR
diff --git a/heartkit/tasks/rhythm/dataloaders/icentia_mini.py b/heartkit/tasks/rhythm/dataloaders/icentia_mini.py
new file mode 100644
index 00000000..a09965de
--- /dev/null
+++ b/heartkit/tasks/rhythm/dataloaders/icentia_mini.py
@@ -0,0 +1,116 @@
+import random
+from typing import Generator, Iterable
+
+import numpy as np
+import numpy.typing as npt
+import physiokit as pk
+import neuralspot_edge as nse
+
+from ....datasets import HKDataloader, IcentiaMiniDataset, IcentiaMiniRhythm
+from ..defines import HKRhythm
+
+IcentiaMiniRhythmMap = {
+ IcentiaMiniRhythm.normal: HKRhythm.sr,
+ IcentiaMiniRhythm.afib: HKRhythm.afib,
+ IcentiaMiniRhythm.aflut: HKRhythm.aflut,
+ IcentiaMiniRhythm.end: HKRhythm.noise,
+}
+
+
+class IcentiaMiniDataloader(HKDataloader):
+ def __init__(self, ds: IcentiaMiniDataset, **kwargs):
+ super().__init__(ds=ds, **kwargs)
+ # Update label map to map icentia mini label -> rhythm label -> user label
+ if self.label_map:
+ self.label_map = {k: self.label_map[v] for (k, v) in IcentiaMiniRhythmMap.items() if v in self.label_map}
+ self.label_type = "rhythm"
+ self._pts_rhythm_map: dict[int, dict[int, tuple[int, int, int]]] = {}
+
+ def _create_patient_rhythm_map(self, patient_id: int):
+ label_key = self.ds.label_key(self.label_type)
+ tgt_labels = sorted(set(self.label_map.values()))
+ # input_size = int(np.ceil((self.ds.sampling_rate / self.sampling_rate) * self.frame_size))
+
+ pt_rhythm_map = {lbl: [] for lbl in tgt_labels}
+ with self.ds.patient_data(patient_id) as pt:
+ # rlabels is a mask with shape (N, M)
+ rlabels = pt[label_key][:]
+
+ # Capture all rhythm locations
+ self.pts_rhythm_map: dict[int, tuple[int, int, int]] = {lbl: [] for lbl in tgt_labels}
+ for r in range(rlabels.shape[0]):
+ # Grab start and end locations by diffing the mask
+ starts = np.concatenate(([0], np.where(np.abs(np.diff(rlabels[r, :])) >= 1)[0]))
+ ends = np.concatenate((starts[1:], [rlabels.shape[1]]))
+ lengths = ends - starts
+ labels = rlabels[r, starts]
+ # iterate through the zip of labels, starts, ends and append to the rhythm map
+ for label, start, length in zip(labels, starts, lengths):
+ # Skip if label is not in the label map
+ if label not in self.label_map:
+ continue
+ # # Skip if the segment is too short
+ # if length < input_size:
+ # continue
+ pt_rhythm_map[self.label_map[label]].append((r, start, length))
+ # END FOR
+ # END FOR
+ # END WITH
+ self._pts_rhythm_map[patient_id] = pt_rhythm_map
+
+ def patient_data_generator(
+ self,
+ patient_id: int,
+ samples_per_patient: list[int],
+ ):
+ tgt_labels = sorted(set(self.label_map.values()))
+ input_size = int(np.ceil((self.ds.sampling_rate / self.sampling_rate) * self.frame_size))
+
+ # Create rhythm map for all patients if needed
+ if patient_id not in self._pts_rhythm_map:
+ self._create_patient_rhythm_map(patient_id)
+
+ with self.ds.patient_data(patient_id) as pt:
+ data = pt["data"][:] # has shape (N, M, 1)
+ pt_rhythm_map = self._pts_rhythm_map[patient_id]
+ for i, samples in enumerate(samples_per_patient):
+ tgt_label = tgt_labels[i]
+ locs = pt_rhythm_map.get(tgt_label, None)
+ if not locs:
+ continue
+ loc_indices = random.choices(range(len(locs)), k=samples)
+ for loc_idx in loc_indices:
+ row, start, length = locs[loc_idx]
+ frame_start = max(0, random.randint(start, max(start, start + length - input_size) + 1))
+ frame_end = frame_start + input_size
+ x = data[row, frame_start:frame_end].astype(np.float32)
+ if self.ds.sampling_rate != self.sampling_rate:
+ x = pk.signal.resample_signal(x, self.ds.sampling_rate, self.sampling_rate, axis=0)
+ x = x[: self.frame_size] # truncate to frame size
+ yield x, tgt_label
+ # END FOR
+ # END FOR
+ # END WITH
+
+ def data_generator(
+ self,
+ patient_ids: list[int],
+ samples_per_patient: int | list[int],
+ shuffle: bool = False,
+ ) -> Generator[tuple[npt.NDArray, npt.NDArray], None, None]:
+ # Target labels and mapping
+ tgt_labels = sorted(set(self.label_map.values()))
+ num_classes = len(tgt_labels)
+
+ # If samples_per_patient is a list, then it must be the same length as nclasses
+ if isinstance(samples_per_patient, Iterable):
+ samples_per_tgt = samples_per_patient
+ else:
+ num_per_tgt = int(max(1, samples_per_patient / num_classes))
+ samples_per_tgt = num_per_tgt * [num_classes]
+
+ for pt_id in nse.utils.uniform_id_generator(patient_ids, shuffle=shuffle):
+ for x, y in self.patient_data_generator(pt_id, samples_per_tgt):
+ yield x, y
+ # END FOR
+ # END FOR
diff --git a/heartkit/tasks/rhythm/dataloaders/lsad.py b/heartkit/tasks/rhythm/dataloaders/lsad.py
index a118acf0..84b1bbd4 100644
--- a/heartkit/tasks/rhythm/dataloaders/lsad.py
+++ b/heartkit/tasks/rhythm/dataloaders/lsad.py
@@ -1,9 +1,9 @@
from typing import Generator
import numpy.typing as npt
+import neuralspot_edge as nse
-from ....datasets.defines import PatientGenerator
-from ....datasets.lsad import LsadDataset, LsadScpCode
+from ....datasets import HKDataloader, LsadDataset, LsadScpCode
from ..defines import HKRhythm
LsadRhythmMap = {
@@ -31,51 +31,25 @@
}
-def lsad_label_map(
- label_map: dict[int, int] | None = None,
-) -> dict[int, int]:
- """Get label map
-
- Args:
- label_map (dict[int, int]|None): Label map
-
- Returns:
- dict[int, int]: Label map
- """
- return {k: label_map.get(v, -1) for (k, v) in LsadRhythmMap.items()}
-
-
-def lsad_data_generator(
- patient_generator: PatientGenerator,
- ds: LsadDataset,
- frame_size: int,
- samples_per_patient: int | list[int] = 1,
- target_rate: int | None = None,
- label_map: dict[int, int] | None = None,
-) -> Generator[tuple[npt.NDArray, int], None, None]:
- """Generate frames w/ rhythm labels (e.g. afib) using patient generator.
-
- Args:
- patient_generator (PatientGenerator): Patient Generator
- ds: LsadDataset
- frame_size (int): Frame size
- samples_per_patient (int | list[int], optional): # samples per patient. Defaults to 1.
- target_rate (int|None, optional): Target rate. Defaults to None.
- label_map (dict[int, int] | None, optional): Label map. Defaults to None.
-
- Returns:
- SampleGenerator: Sample generator
-
- Yields:
- Iterator[SampleGenerator]
- """
-
- return ds.signal_label_generator(
- patient_generator=patient_generator,
- frame_size=frame_size,
- samples_per_patient=samples_per_patient,
- target_rate=target_rate,
- label_map=lsad_label_map(label_map=label_map),
- label_type="scp",
- label_format=None,
- )
+class LsadDataloader(HKDataloader):
+ def __init__(self, ds: LsadDataset, **kwargs):
+ super().__init__(ds=ds, **kwargs)
+ if self.label_map:
+ self.label_map = {k: self.label_map[v] for (k, v) in LsadRhythmMap.items() if v in self.label_map}
+ self.label_type = "scp"
+
+ def data_generator(
+ self,
+ patient_ids: list[int],
+ samples_per_patient: int | list[int],
+ shuffle: bool = False,
+ ) -> Generator[tuple[npt.NDArray, npt.NDArray], None, None]:
+ return self.ds.signal_label_generator(
+ patient_generator=nse.utils.uniform_id_generator(patient_ids, repeat=True, shuffle=shuffle),
+ frame_size=self.frame_size,
+ samples_per_patient=samples_per_patient,
+ target_rate=self.sampling_rate,
+ label_map=self.label_map,
+ label_type=self.label_type,
+ label_format=None,
+ )
diff --git a/heartkit/tasks/rhythm/dataloaders/ptbxl.py b/heartkit/tasks/rhythm/dataloaders/ptbxl.py
index ec252663..741d1ca2 100644
--- a/heartkit/tasks/rhythm/dataloaders/ptbxl.py
+++ b/heartkit/tasks/rhythm/dataloaders/ptbxl.py
@@ -1,9 +1,9 @@
from typing import Generator
import numpy.typing as npt
+import neuralspot_edge as nse
-from ....datasets.defines import PatientGenerator
-from ....datasets.ptbxl import PtbxlDataset, PtbxlScpCode
+from ....datasets import HKDataloader, PtbxlDataset, PtbxlScpCode
from ..defines import HKRhythm
PtbxlRhythmMap = {
@@ -22,51 +22,25 @@
}
-def ptbxl_label_map(
- label_map: dict[int, int] | None = None,
-) -> dict[int, int]:
- """Get label map
-
- Args:
- label_map (dict[int, int]|None): Label map
-
- Returns:
- dict[int, int]: Label map
- """
- return {k: label_map.get(v, -1) for (k, v) in PtbxlRhythmMap.items()}
-
-
-def ptbxl_data_generator(
- patient_generator: PatientGenerator,
- ds: PtbxlDataset,
- frame_size: int,
- samples_per_patient: int | list[int] = 1,
- target_rate: int | None = None,
- label_map: dict[int, int] | None = None,
-) -> Generator[tuple[npt.NDArray, int], None, None]:
- """Generate frames w/ rhythm labels using patient generator.
-
- Args:
- patient_generator (PatientGenerator): Patient Generator
- ds: PtbxlDataset
- frame_size (int): Frame size
- samples_per_patient (int | list[int], optional): # samples per patient. Defaults to 1.
- target_rate (int|None, optional): Target rate. Defaults to None.
- label_map (dict[int, int] | None, optional): Label map. Defaults to None.
-
- Returns:
- SampleGenerator: Sample generator
-
- Yields:
- Iterator[SampleGenerator]
- """
-
- return ds.signal_label_generator(
- patient_generator=patient_generator,
- frame_size=frame_size,
- samples_per_patient=samples_per_patient,
- target_rate=target_rate,
- label_map=ptbxl_label_map(label_map=label_map),
- label_type="scp",
- label_format=None,
- )
+class PtbxlDataloader(HKDataloader):
+ def __init__(self, ds: PtbxlDataset, **kwargs):
+ super().__init__(ds=ds, **kwargs)
+ if self.label_map:
+ self.label_map = {k: self.label_map[v] for (k, v) in PtbxlRhythmMap.items() if v in self.label_map}
+ self.label_type = "scp"
+
+ def data_generator(
+ self,
+ patient_ids: list[int],
+ samples_per_patient: int | list[int],
+ shuffle: bool = False,
+ ) -> Generator[tuple[npt.NDArray, npt.NDArray], None, None]:
+ return self.ds.signal_label_generator(
+ patient_generator=nse.utils.uniform_id_generator(patient_ids, repeat=True, shuffle=shuffle),
+ frame_size=self.frame_size,
+ samples_per_patient=samples_per_patient,
+ target_rate=self.sampling_rate,
+ label_map=self.label_map,
+ label_type=self.label_type,
+ label_format=None,
+ )
diff --git a/heartkit/tasks/rhythm/datasets.py b/heartkit/tasks/rhythm/datasets.py
index d0c23520..72348405 100644
--- a/heartkit/tasks/rhythm/datasets.py
+++ b/heartkit/tasks/rhythm/datasets.py
@@ -1,365 +1,162 @@
-import functools
-import logging
-from pathlib import Path
-
import numpy as np
-import numpy.typing as npt
import tensorflow as tf
+import neuralspot_edge as nse
from ...datasets import (
HKDataset,
- augment_pipeline,
- preprocess_pipeline,
- uniform_id_generator,
-)
-from ...datasets.dataloader import test_dataloader, train_val_dataloader
-from ...defines import (
- AugmentationParams,
- HKExportParams,
- HKTestParams,
- HKTrainParams,
- PreprocessParams,
-)
-from ...utils import resolve_template_path
-from .dataloaders import (
- icentia11k_data_generator,
- icentia11k_label_map,
- lsad_data_generator,
- lsad_label_map,
- ptbxl_data_generator,
- ptbxl_label_map,
+ create_augmentation_pipeline,
)
+from ...datasets.dataloader import HKDataloader
+from ...defines import HKTaskParams, NamedParams
-logger = logging.getLogger(__name__)
-
-
-def preprocess(x: npt.NDArray, preprocesses: list[PreprocessParams], sample_rate: float) -> npt.NDArray:
- """Preprocess data pipeline
-
- Args:
- x (npt.NDArray): Input data
- preprocesses (list[PreprocessParams]): Preprocess parameters
- sample_rate (float): Sample rate
-
- Returns:
- npt.NDArray: Preprocessed data
- """
- return preprocess_pipeline(x, preprocesses=preprocesses, sample_rate=sample_rate)
-
-
-def augment(x: npt.NDArray, augmentations: list[AugmentationParams], sample_rate: float) -> npt.NDArray:
- """Augment data pipeline
-
- Args:
- x (npt.NDArray): Input data
- augmentations (list[AugmentationParams]): Augmentation parameters
- sample_rate (float): Sample rate
-
- Returns:
- npt.NDArray: Augmented data
- """
- return augment_pipeline(
- x=x,
- augmentations=augmentations,
- sample_rate=sample_rate,
- )
-
-
-def prepare(
- x_y: tuple[npt.NDArray, int],
- sample_rate: float,
- preprocesses: list[PreprocessParams],
- augmentations: list[AugmentationParams],
- spec: tuple[tf.TensorSpec, tf.TensorSpec],
- num_classes: int,
-) -> tuple[npt.NDArray, npt.NDArray]:
- """Prepare dataset
-
- Args:
- x_y (tuple[npt.NDArray, int]): Input data and label
- sample_rate (float): Sample rate
- preprocesses (list[PreprocessParams]|None): Preprocess parameters
- augmentations (list[AugmentationParams]|None): Augmentation parameters
- spec (tuple[tf.TensorSpec, tf.TensorSpec]): TensorSpec
- num_classes (int): Number of classes
-
- Returns:
- tuple[npt.NDArray, npt.NDArray]: Prepared data
- """
- x, y = x_y[0].copy(), x_y[1]
-
- if augmentations:
- x = augment(x, augmentations, sample_rate)
- # END IF
-
- if preprocesses:
- x = preprocess(x, preprocesses, sample_rate)
- # END IF
-
- x = x.reshape(spec[0].shape)
- y = tf.one_hot(y, num_classes)
-
- return x, y
-
-
-def get_ds_label_map(ds: HKDataset, label_map: dict[int, int] | None = None) -> dict[int, int]:
- """Get label map for dataset
+from .dataloaders import RhythmDataloaderFactory
- Args:
- ds (HKDataset): Dataset
- label_map (dict[int, int]|None): Label map
+logger = nse.utils.setup_logger(__name__)
- Returns:
- dict[int, int]: Label map
- """
- match ds.name:
- case "icentia11k":
- return icentia11k_label_map(label_map=label_map)
- case "lsad":
- return lsad_label_map(label_map=label_map)
- case "ptbxl":
- return ptbxl_label_map(label_map=label_map)
- case _:
- raise ValueError(f"Dataset {ds.name} not supported")
- # END MATCH
-
-def get_data_generator(
- ds: HKDataset,
- frame_size: int,
- samples_per_patient: int,
- target_rate: int,
- label_map: dict[int, int] | None = None,
+def create_data_pipeline(
+ ds: tf.data.Dataset,
+ sampling_rate: int,
+ batch_size: int,
+ buffer_size: int | None = None,
+ augmentations: list[NamedParams] | None = None,
+ num_classes: int = 2,
):
- """Get task data generator for dataset
-
- Args:
- ds (HKDataset): Dataset
- frame_size (int): Frame size
- samples_per_patient (int): Samples per patient
- target_rate (int): Target rate
- label_map (dict[int, int] | None, optional): Label map. Defaults to None.
-
- Returns:
- callable: Data generator
- """
- match ds.name:
- case "icentia11k":
- data_generator = icentia11k_data_generator
- case "lsad":
- data_generator = lsad_data_generator
- case "ptbxl":
- data_generator = ptbxl_data_generator
- case _:
- raise ValueError(f"Dataset {ds.name} not supported")
- # END MATCH
- return functools.partial(
- data_generator,
- ds=ds,
- frame_size=frame_size,
- samples_per_patient=samples_per_patient,
- target_rate=target_rate,
- label_map=label_map,
- )
-
-
-def get_label_type(ds: HKDataset) -> str:
- """Get label type for dataset
-
- Args:
- ds (HKDataset): Dataset
-
- Returns:
- str: Label type
- """
- match ds.name:
- case "icentia11k":
- return "rhythm"
- case _:
- return "scp"
- # END MATCH
-
-
-def resolve_ds_cache_path(fpath: Path | None, ds: HKDataset, task: str, frame_size: int, sample_rate: int):
- """Resolve dataset cache path
-
- Args:
- fpath (Path|None): File path
- ds (HKDataset): Dataset
- task (str): Task
- frame_size (int): Frame size
- sample_rate (int): Sampling rate
-
- Returns:
- Path|None: Resolved path
- """
- if not fpath:
- return None
- return resolve_template_path(
- fpath=fpath,
- dataset=ds.name,
- task=task,
- frame_size=frame_size,
- sampling_rate=sample_rate,
+ if buffer_size:
+ ds = ds.shuffle(
+ buffer_size=buffer_size,
+ reshuffle_each_iteration=True,
+ )
+ if batch_size:
+ ds = ds.batch(
+ batch_size=batch_size,
+ drop_remainder=True,
+ num_parallel_calls=tf.data.AUTOTUNE,
+ )
+ augmenter = create_augmentation_pipeline(augmentations, sampling_rate=sampling_rate)
+
+ ds = (
+ ds.map(
+ lambda data, labels: {
+ "data": tf.cast(data, "float32"),
+ "labels": tf.one_hot(labels, num_classes),
+ },
+ num_parallel_calls=tf.data.AUTOTUNE,
+ )
+ .map(
+ augmenter,
+ num_parallel_calls=tf.data.AUTOTUNE,
+ )
+ .map(
+ lambda data: (data["data"], data["labels"]),
+ num_parallel_calls=tf.data.AUTOTUNE,
+ )
)
+ return ds.prefetch(tf.data.AUTOTUNE)
def load_train_datasets(
datasets: list[HKDataset],
- params: HKTrainParams,
- ds_spec: tuple[tf.TensorSpec, tf.TensorSpec],
+ params: HKTaskParams,
) -> tuple[tf.data.Dataset, tf.data.Dataset]:
- """Load training and validation datasets
-
- Args:
- datasets (list[HKDataset]): Datasets
- params (HKTrainParams): Training parameters
- ds_spec (tuple[tf.TensorSpec, tf.TensorSpec]): TensorSpec
-
- Returns:
- tuple[tf.data.Dataset, tf.data.Dataset]: Train and validation datasets
- """
- id_generator = functools.partial(uniform_id_generator, repeat=True)
- train_prepare = functools.partial(
- prepare,
- sample_rate=params.sampling_rate,
- preprocesses=params.preprocesses,
- augmentations=params.augmentations,
- spec=ds_spec,
- num_classes=params.num_classes,
- )
- val_prepare = functools.partial(
- prepare,
- sample_rate=params.sampling_rate,
- preprocesses=params.preprocesses,
- augmentations=None,
- spec=ds_spec,
- num_classes=params.num_classes,
- )
-
train_datasets = []
val_datasets = []
for ds in datasets:
- val_file = resolve_ds_cache_path(
- params.val_file,
- ds=ds,
- task="rhythm",
- frame_size=params.frame_size,
- sample_rate=params.sampling_rate,
- )
- data_generator = get_data_generator(
+ dataloader: HKDataloader = RhythmDataloaderFactory.get(ds.name)(
ds=ds,
frame_size=params.frame_size,
- samples_per_patient=params.samples_per_patient,
- target_rate=params.sampling_rate,
+ sampling_rate=params.sampling_rate,
label_map=params.class_map,
)
- train_ds, val_ds = train_val_dataloader(
- ds=ds,
- spec=ds_spec,
- data_generator=data_generator,
- id_generator=id_generator,
+ train_patients, val_patients = dataloader.split_train_val_patients(
train_patients=params.train_patients,
val_patients=params.val_patients,
- val_pt_samples=params.val_samples_per_patient,
- val_file=val_file,
- val_size=params.val_size,
- label_map=get_ds_label_map(ds, label_map=params.class_map),
- label_type=get_label_type(ds),
- preprocess=train_prepare,
- val_preprocess=val_prepare,
- num_workers=params.data_parallelism,
+ )
+
+ train_ds = dataloader.create_dataloader(
+ patient_ids=train_patients, samples_per_patient=params.samples_per_patient, shuffle=True
+ )
+
+ val_ds = dataloader.create_dataloader(
+ patient_ids=val_patients, samples_per_patient=params.val_samples_per_patient, shuffle=False
)
train_datasets.append(train_ds)
val_datasets.append(val_ds)
# END FOR
- ds_weights = np.array([d.weight for d in params.datasets])
- ds_weights = ds_weights / ds_weights.sum()
+ ds_weights = None
+ if params.dataset_weights:
+ ds_weights = np.array(params.dataset_weights)
+ ds_weights = ds_weights / ds_weights.sum()
train_ds = tf.data.Dataset.sample_from_datasets(train_datasets, weights=ds_weights)
val_ds = tf.data.Dataset.sample_from_datasets(val_datasets, weights=ds_weights)
# Shuffle and batch datasets for training
- train_ds = (
- train_ds.shuffle(
- buffer_size=params.buffer_size,
- reshuffle_each_iteration=True,
- ).batch(
- batch_size=params.batch_size,
- drop_remainder=True,
- num_parallel_calls=tf.data.AUTOTUNE,
- )
- # .prefetch(buffer_size=tf.data.AUTOTUNE)
+ train_ds = create_data_pipeline(
+ ds=train_ds,
+ sampling_rate=params.sampling_rate,
+ batch_size=params.batch_size,
+ buffer_size=params.buffer_size,
+ augmentations=params.augmentations + params.preprocesses,
+ num_classes=params.num_classes,
)
- val_ds = val_ds.batch(
+
+ val_ds = create_data_pipeline(
+ ds=val_ds,
+ sampling_rate=params.sampling_rate,
batch_size=params.batch_size,
- drop_remainder=True,
- num_parallel_calls=tf.data.AUTOTUNE,
+ augmentations=params.preprocesses,
+ num_classes=params.num_classes,
)
+
+ # If given fixed val size or steps, then capture and cache
+ val_steps_per_epoch = params.val_size // params.batch_size if params.val_size else params.val_steps_per_epoch
+ if val_steps_per_epoch:
+ logger.info(f"Validation steps per epoch: {val_steps_per_epoch}")
+ val_ds = val_ds.take(val_steps_per_epoch).cache()
+
return train_ds, val_ds
def load_test_dataset(
datasets: list[HKDataset],
- params: HKTestParams | HKExportParams,
- ds_spec: tuple[tf.TensorSpec, tf.TensorSpec],
+ params: HKTaskParams,
) -> tf.data.Dataset:
- """Load test dataset
-
- Args:
- datasets (list[HKDataset]): Datasets
- params (HKTestParams|HKExportParams): Test parameters
- ds_spec (tuple[tf.TensorSpec, tf.TensorSpec]): TensorSpec
-
- Returns:
- tf.data.Dataset: Test dataset
- """
-
- id_generator = functools.partial(uniform_id_generator, repeat=True)
- test_prepare = functools.partial(
- prepare,
- sample_rate=params.sampling_rate,
- preprocesses=params.preprocesses,
- augmentations=None, # params.augmentations,
- spec=ds_spec,
- num_classes=params.num_classes,
- )
test_datasets = []
for ds in datasets:
- test_file = resolve_ds_cache_path(
- fpath=params.test_file,
+ dataloader: HKDataloader = RhythmDataloaderFactory.get(ds.name)(
ds=ds,
- task="rhythm",
frame_size=params.frame_size,
- sample_rate=params.sampling_rate,
- )
- data_generator = get_data_generator(
- ds=ds,
- frame_size=params.frame_size,
- samples_per_patient=params.test_samples_per_patient,
- target_rate=params.sampling_rate,
+ sampling_rate=params.sampling_rate,
label_map=params.class_map,
)
- test_ds = test_dataloader(
- ds=ds,
- spec=ds_spec,
- data_generator=data_generator,
- id_generator=id_generator,
- test_patients=params.test_patients,
- test_file=test_file,
- label_map=get_ds_label_map(ds, label_map=params.class_map),
- label_type=get_label_type(ds),
- preprocess=test_prepare,
- num_workers=params.data_parallelism,
+ test_patients = dataloader.test_patient_ids(params.test_patients)
+ test_ds = dataloader.create_dataloader(
+ patient_ids=test_patients,
+ samples_per_patient=params.test_samples_per_patient,
+ shuffle=False,
)
test_datasets.append(test_ds)
# END FOR
- ds_weights = np.array([d.weight for d in params.datasets])
- ds_weights = ds_weights / ds_weights.sum()
+ ds_weights = None
+ if params.dataset_weights:
+ ds_weights = np.array(params.dataset_weights)
+ ds_weights = ds_weights / ds_weights.sum()
test_ds = tf.data.Dataset.sample_from_datasets(test_datasets, weights=ds_weights)
- # END WITH
+ test_ds = create_data_pipeline(
+ ds=test_ds,
+ sampling_rate=params.sampling_rate,
+ batch_size=params.batch_size,
+ augmentations=params.preprocesses,
+ num_classes=params.num_classes,
+ )
+
+ if params.test_size:
+ batch_size = getattr(params, "batch_size", 1)
+ test_ds = test_ds.take(params.test_size // batch_size).cache()
+
return test_ds
diff --git a/heartkit/tasks/rhythm/demo.py b/heartkit/tasks/rhythm/demo.py
index 5dc27bdf..52fef696 100644
--- a/heartkit/tasks/rhythm/demo.py
+++ b/heartkit/tasks/rhythm/demo.py
@@ -5,22 +5,20 @@
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from tqdm import tqdm
+import neuralspot_edge as nse
-from ...datasets.utils import uniform_id_generator
-from ...defines import HKDemoParams
+from ...defines import HKTaskParams
from ...rpc import BackendFactory
-from ...utils import setup_logger
-from ..utils import load_datasets
-from .datasets import preprocess
+from ...datasets import DatasetFactory, create_augmentation_pipeline
-def demo(params: HKDemoParams):
+def demo(params: HKTaskParams):
"""Run demo for model
Args:
- params (HKDemoParams): Demo parameters
+ params (HKTaskParams): Demo parameters
"""
- logger = setup_logger(__name__, level=params.verbose)
+ logger = nse.utils.setup_logger(__name__, level=params.verbose)
bg_color = "rgba(38,42,50,1.0)"
primary_color = "#11acd5"
@@ -34,31 +32,29 @@ def demo(params: HKDemoParams):
params.demo_size = params.demo_size or 2 * params.frame_size
# Load backend inference engine
- runner = BackendFactory.create(params.backend, params=params)
+ runner = BackendFactory.get(params.backend)(params=params)
# Load data
# classes = sorted(list(set(params.class_map.values())))
class_names = params.class_names or [f"Class {i}" for i in range(params.num_classes)]
feat_shape = (params.frame_size, 1)
- # class_shape = (params.num_classes,)
- # input_spec = (
- # tf.TensorSpec(shape=feat_shape, dtype=tf.float32),
- # tf.TensorSpec(shape=class_shape, dtype=tf.int32),
- # )
-
- dsets = load_datasets(datasets=params.datasets)
- ds = random.choice(dsets)
+ datasets = [DatasetFactory.get(ds.name)(**ds.params) for ds in params.datasets]
+ ds = random.choice(datasets)
ds_gen = ds.signal_generator(
- patient_generator=uniform_id_generator(ds.get_test_patient_ids(), repeat=False),
+ patient_generator=nse.utils.uniform_id_generator(ds.get_test_patient_ids(), repeat=False),
frame_size=params.demo_size,
samples_per_patient=5,
target_rate=params.sampling_rate,
)
x = next(ds_gen)
+ augmenter = create_augmentation_pipeline(
+ params.preprocesses + params.augmentations, sampling_rate=params.sampling_rate
+ )
+
# Run inference
runner.open()
logger.debug("Running inference")
@@ -68,13 +64,9 @@ def demo(params: HKDemoParams):
start, stop = x.shape[0] - params.frame_size, x.shape[0]
else:
start, stop = i, i + params.frame_size
- xx = preprocess(
- x[start:stop],
- sample_rate=params.sampling_rate,
- preprocesses=params.preprocesses,
- )
-
+ xx = x[start:stop]
xx = xx.reshape(feat_shape)
+ xx = augmenter(xx, training=False)
runner.set_inputs(xx)
runner.perform_inference()
yy = runner.get_outputs()
diff --git a/heartkit/tasks/rhythm/evaluate.py b/heartkit/tasks/rhythm/evaluate.py
index b0ca3158..4bf43e63 100644
--- a/heartkit/tasks/rhythm/evaluate.py
+++ b/heartkit/tasks/rhythm/evaluate.py
@@ -1,49 +1,34 @@
-import logging
import os
import numpy as np
-import tensorflow as tf
-from sklearn.metrics import f1_score
-
+import keras
import neuralspot_edge as nse
-from ...defines import HKTestParams
-from ...utils import set_random_seed, setup_logger
-from ..utils import load_datasets
+
+from ...defines import HKTaskParams
+from ...datasets import DatasetFactory
from .datasets import load_test_dataset
-def evaluate(params: HKTestParams):
+def evaluate(params: HKTaskParams):
"""Evaluate model
Args:
- params (HKTestParams): Evaluation parameters
+ params (HKTaskParams): Evaluation parameters
"""
- logger = setup_logger(__name__, level=params.verbose)
-
- params.seed = set_random_seed(params.seed)
- logger.debug(f"Random seed {params.seed}")
-
os.makedirs(params.job_dir, exist_ok=True)
+ logger = nse.utils.setup_logger(__name__, level=params.verbose, file_path=params.job_dir / "test.log")
logger.debug(f"Creating working directory in {params.job_dir}")
- handler = logging.FileHandler(params.job_dir / "test.log", mode="w")
- handler.setLevel(logging.INFO)
- logger.addHandler(handler)
+ params.seed = nse.utils.set_random_seed(params.seed)
+ logger.debug(f"Random seed {params.seed}")
class_names = params.class_names or [f"Class {i}" for i in range(params.num_classes)]
- feat_shape = (params.frame_size, 1)
- class_shape = (params.num_classes,)
-
- ds_spec = (
- tf.TensorSpec(shape=feat_shape, dtype=tf.float32),
- tf.TensorSpec(shape=class_shape, dtype=tf.int32),
- )
-
- datasets = load_datasets(datasets=params.datasets)
+ datasets = [DatasetFactory.get(ds.name)(**ds.params) for ds in params.datasets]
- test_ds = load_test_dataset(datasets=datasets, params=params, ds_spec=ds_spec)
- test_x, test_y = next(test_ds.batch(params.test_size).as_numpy_iterator())
+ test_ds = load_test_dataset(datasets=datasets, params=params)
+ test_x = np.concatenate([x for x, _ in test_ds.as_numpy_iterator()])
+ test_y = np.concatenate([y for _, y in test_ds.as_numpy_iterator()])
logger.debug("Loading model")
model = nse.models.load_model(params.model_file)
@@ -53,35 +38,26 @@ def evaluate(params: HKTestParams):
logger.debug(f"Model requires {flops/1e6:0.2f} MFLOPS")
logger.debug("Performing inference")
- y_true = np.argmax(test_y, axis=-1)
- y_prob = tf.nn.softmax(model.predict(test_x)).numpy()
- y_pred = np.argmax(y_prob, axis=-1)
-
- # Summarize results
- logger.info("Testing Results")
- test_acc = np.sum(y_pred == y_true) / len(y_true)
- test_f1 = f1_score(y_true, y_pred, average="weighted")
- logger.info(f"[TEST SET] ACC={test_acc:.2%}, F1={test_f1:.2%}")
-
- if params.num_classes == 2:
- roc_path = params.job_dir / "roc_auc_test.png"
- nse.plotting.roc.roc_auc_plot(y_true, y_prob[:, 1], labels=class_names, save_path=roc_path)
- # END IF
+ rst = model.evaluate(test_ds, verbose=params.verbose, return_dict=True)
+ logger.info("[TEST SET] " + ", ".join([f"{k.upper()}={v:.2%}" for k, v in rst.items()]))
# If threshold given, only count predictions above threshold
+ y_true = np.argmax(test_y, axis=-1)
+ y_prob = keras.ops.softmax(model.predict(test_x, verbose=params.verbose)).numpy()
+ y_pred = np.argmax(y_prob, axis=-1)
if params.threshold:
prev_numel = len(y_true)
- y_prob, y_pred, y_true = nse.metrics.threshold.threshold_predictions(y_prob, y_pred, y_true, params.threshold)
- drop_perc = 1 - len(y_true) / prev_numel
- test_acc = np.sum(y_pred == y_true) / len(y_true)
- test_f1 = f1_score(y_true, y_pred, average="weighted")
- logger.info(f"[TEST SET] THRESH={params.threshold:0.2%}, DROP={drop_perc:.2%}")
- logger.info(f"[TEST SET] ACC={test_acc:.2%}, F1={test_f1:.2%}")
+ indices = nse.metrics.threshold.get_predicted_threshold_indices(y_prob, y_pred, params.threshold)
+ test_x, test_y = test_x[indices], test_y[indices]
+ y_true, y_pred = y_true[indices], y_pred[indices]
+ rst = model.evaluate(test_x, test_y, verbose=params.verbose, return_dict=True)
+ logger.info(f"[TEST SET] THRESH={params.threshold:0.2%}, DROP={1 - len(indices) / prev_numel:.2%}")
+ logger.info("[TEST SET] " + ", ".join([f"{k.upper()}={v:.2%}" for k, v in rst.items()]))
# END IF
cm_path = params.job_dir / "confusion_matrix_test.png"
- nse.plotting.cm.confusion_matrix_plot(y_true, y_pred, labels=class_names, save_path=cm_path, normalize="true")
- nse.plotting.cm.px_plot_confusion_matrix(
+ nse.plotting.confusion_matrix_plot(y_true, y_pred, labels=class_names, save_path=cm_path, normalize="true")
+ nse.plotting.px_plot_confusion_matrix(
y_true,
y_pred,
labels=class_names,
diff --git a/heartkit/tasks/rhythm/export.py b/heartkit/tasks/rhythm/export.py
index 7da19db8..2bfb0ff0 100644
--- a/heartkit/tasks/rhythm/export.py
+++ b/heartkit/tasks/rhythm/export.py
@@ -5,23 +5,20 @@
import keras
import numpy as np
-import tensorflow as tf
-from sklearn.metrics import f1_score
-
import neuralspot_edge as nse
-from ...defines import HKExportParams
-from ...utils import setup_logger
-from ..utils import load_datasets
+
+from ...defines import HKTaskParams
+from ...datasets import DatasetFactory
from .datasets import load_test_dataset
-def export(params: HKExportParams):
+def export(params: HKTaskParams):
"""Export model
Args:
- params (HKExportParams): Deployment parameters
+ params (HKTaskParams): Deployment parameters
"""
- logger = setup_logger(__name__, level=params.verbose)
+ logger = nse.utils.setup_logger(__name__, level=params.verbose)
os.makedirs(params.job_dir, exist_ok=True)
logger.debug(f"Creating working directory in {params.job_dir}")
@@ -33,41 +30,24 @@ def export(params: HKExportParams):
tfl_model_path = params.job_dir / "model.tflite"
tflm_model_path = params.job_dir / "model_buffer.h"
- # classes = sorted(list(set(params.class_map.values())))
- # class_names = params.class_names or [f"Class {i}" for i in range(params.num_classes)]
-
feat_shape = (params.frame_size, 1)
- class_shape = (params.num_classes,)
-
- ds_spec = (
- tf.TensorSpec(shape=feat_shape, dtype=tf.float32),
- tf.TensorSpec(shape=class_shape, dtype=tf.int32),
- )
- datasets = load_datasets(datasets=params.datasets)
+ datasets = [DatasetFactory.get(ds.name)(**ds.params) for ds in params.datasets]
- test_ds = load_test_dataset(datasets=datasets, params=params, ds_spec=ds_spec)
- test_x, test_y = next(test_ds.batch(params.test_size).as_numpy_iterator())
+ test_ds = load_test_dataset(datasets=datasets, params=params)
+ test_x = np.concatenate([x for x, _ in test_ds.as_numpy_iterator()])
+ test_y = np.concatenate([y for _, y in test_ds.as_numpy_iterator()])
# Load model and set fixed batch size of 1
logger.debug("Loading trained model")
model = nse.models.load_model(params.model_file)
+ # Add softmax layer if required
if not params.use_logits and not isinstance(model.layers[-1], keras.layers.Softmax):
- last_layer_name = model.layers[-1].name
-
- def call_function(layer, *args, **kwargs):
- out = layer(*args, **kwargs)
- if layer.name == last_layer_name:
- out = keras.layers.Softmax()(out)
- return out
-
- # END DEF
- model_clone = keras.models.clone_model(model, call_function=call_function)
- model_clone.set_weights(model.get_weights())
- model = model_clone
+ model = nse.models.append_layers(model, layers=[keras.layers.Softmax()], copy_weights=True)
# END IF
- inputs = keras.Input(shape=ds_spec[0].shape, batch_size=1, name="input", dtype=ds_spec[0].dtype.name)
+
+ inputs = keras.Input(shape=feat_shape, batch_size=1, name="input", dtype="float32")
model(inputs)
flops = nse.metrics.flops.get_flops(model, batch_size=1, fpath=params.job_dir / "model_flops.log")
@@ -102,25 +82,32 @@ def call_function(layer, *args, **kwargs):
tflite.compile()
# Verify TFLite results match TF results
+ metrics = [
+ keras.metrics.CategoricalCrossentropy(name="loss", from_logits=params.use_logits),
+ keras.metrics.CategoricalAccuracy(name="acc"),
+ keras.metrics.F1Score(name="f1", average="weighted"),
+ ]
+
+ if params.val_metric not in [m.name for m in metrics]:
+ raise ValueError(f"Metric {params.val_metric} not supported")
+
logger.info("Validating model results")
- y_true = np.argmax(test_y, axis=-1)
- y_pred_tf = np.argmax(model.predict(test_x), axis=-1)
- y_pred_tfl = np.argmax(tflite.predict(x=test_x), axis=-1)
+ y_true = test_y
+ y_pred_tf = model.predict(test_x)
+ y_pred_tfl = tflite.predict(x=test_x)
- tf_acc = np.sum(y_true == y_pred_tf) / y_true.size
- tf_f1 = f1_score(y_true, y_pred_tf, average="weighted")
- logger.info(f"[TF SET] ACC={tf_acc:.2%}, F1={tf_f1:.2%}")
+ tf_rst = nse.metrics.compute_metrics(metrics, y_true, y_pred_tf)
+ tfl_rst = nse.metrics.compute_metrics(metrics, y_true, y_pred_tfl)
+ logger.info("[TF METRICS] " + " ".join([f"{k.upper()}={v:.2%}" for k, v in tf_rst.items()]))
+ logger.info("[TFL METRICS] " + " ".join([f"{k.upper()}={v:.2%}" for k, v in tfl_rst.items()]))
- tfl_acc = np.sum(y_true == y_pred_tfl) / y_true.size
- tfl_f1 = f1_score(y_true, y_pred_tfl, average="weighted")
- logger.info(f"[TFL SET] ACC={tfl_acc:.2%}, F1={tfl_f1:.2%}")
+ metric_diff = abs(tf_rst[params.val_metric] - tfl_rst[params.val_metric])
# Check accuracy hit
- tfl_acc_drop = max(0, tf_acc - tfl_acc)
- if params.val_acc_threshold is not None and (1 - tfl_acc_drop) < params.val_acc_threshold:
- logger.warning(f"TFLite accuracy dropped by {tfl_acc_drop:0.2%}")
- elif params.val_acc_threshold:
- logger.info(f"Validation passed ({tfl_acc_drop:0.2%})")
+ if params.val_metric_threshold is not None and metric_diff > params.val_metric_threshold:
+ logger.warning(f"TFLite accuracy dropped by {metric_diff:0.2%}")
+ elif params.val_metric_threshold:
+ logger.info(f"Validation passed ({metric_diff:0.2%})")
if params.tflm_file and tflm_model_path != params.tflm_file:
logger.debug(f"Copying TFLM header to {params.tflm_file}")
diff --git a/heartkit/tasks/rhythm/train.py b/heartkit/tasks/rhythm/train.py
index c6343403..0d4977c6 100644
--- a/heartkit/tasks/rhythm/train.py
+++ b/heartkit/tasks/rhythm/train.py
@@ -1,20 +1,17 @@
-import logging
import os
import keras
import numpy as np
import sklearn.utils
-import tensorflow as tf
import wandb
-from sklearn.metrics import f1_score
from wandb.keras import WandbMetricsLogger, WandbModelCheckpoint
-
import neuralspot_edge as nse
-from ...defines import HKTrainParams
-from ...utils import env_flag, set_random_seed, setup_logger
-from ..utils import load_datasets
+
+from ...defines import HKTaskParams
+from ...datasets import DatasetFactory
from .datasets import load_train_datasets
from ...models import ModelFactory
+from ...utils import dark_theme, setup_plotting
class TermineTrainingError(Exception):
@@ -26,28 +23,24 @@ def on_train_end(self, epoch, logs=None):
raise TermineTrainingError("Training stopped by KillerCallBack")
-def train(params: HKTrainParams):
+def train(params: HKTaskParams):
"""Train model
Args:
- params (HKTrainParams): Training parameters
+ params (HKTaskParams): Training parameters
"""
- logger = setup_logger(__name__, level=params.verbose)
-
- params.seed = set_random_seed(params.seed)
- logger.debug(f"Random seed {params.seed}")
-
os.makedirs(params.job_dir, exist_ok=True)
+
+ logger = nse.utils.setup_logger(__name__, level=params.verbose, file_path=params.job_dir / "train.log")
logger.debug(f"Creating working directory in {params.job_dir}")
- handler = logging.FileHandler(params.job_dir / "train.log", mode="w")
- handler.setLevel(logging.INFO)
- logger.addHandler(handler)
+ params.seed = nse.utils.set_random_seed(params.seed)
+ logger.debug(f"Random seed {params.seed}")
with open(params.job_dir / "train_config.json", "w", encoding="utf-8") as fp:
fp.write(params.model_dump_json(indent=2))
- if env_flag("WANDB"):
+ if nse.utils.env_flag("WANDB"):
wandb.init(
project=params.project,
entity="ambiq",
@@ -56,40 +49,27 @@ def train(params: HKTrainParams):
wandb.config.update(params.model_dump())
# END IF
- classes = sorted(list(set(params.class_map.values())))
+ classes = sorted(set(params.class_map.values()))
class_names = params.class_names or [f"Class {i}" for i in range(params.num_classes)]
feat_shape = (params.frame_size, 1)
- class_shape = (params.num_classes,)
- ds_spec = (
- tf.TensorSpec(shape=feat_shape, dtype=tf.float32),
- tf.TensorSpec(shape=class_shape, dtype=tf.int32),
- )
- datasets = load_datasets(datasets=params.datasets)
+ datasets = [DatasetFactory.get(ds.name)(**ds.params) for ds in params.datasets]
- train_ds, val_ds = load_train_datasets(
- datasets=datasets,
- params=params,
- ds_spec=ds_spec,
- )
+ train_ds, val_ds = load_train_datasets(datasets=datasets, params=params)
- test_labels = [label.numpy() for _, label in val_ds]
- y_true = np.argmax(np.concatenate(test_labels), axis=-1)
+ y_true = np.concatenate([y for _, y in val_ds.as_numpy_iterator()])
+ y_true = np.argmax(y_true, axis=-1).flatten()
class_weights = 0.25
if params.class_weights == "balanced":
class_weights = sklearn.utils.compute_class_weight("balanced", classes=np.array(classes), y=y_true)
class_weights = (class_weights + class_weights.mean()) / 2 # Smooth out
+ class_weights = class_weights.tolist()
# END IF
logger.debug(f"Class weights: {class_weights}")
- inputs = keras.Input(
- shape=ds_spec[0].shape,
- batch_size=None,
- name="input",
- dtype=ds_spec[0].dtype.name,
- )
+ inputs = keras.Input(shape=feat_shape, name="input", dtype="float32")
# Load existing model
if params.resume and params.model_file:
@@ -98,6 +78,8 @@ def train(params: HKTrainParams):
params.model_file = None
else:
logger.debug("Creating model from scratch")
+ if params.architecture is None:
+ raise ValueError("Model architecture must be specified")
model = ModelFactory.get(params.architecture.name)(
x=inputs,
params=params.architecture.params,
@@ -107,19 +89,14 @@ def train(params: HKTrainParams):
flops = nse.metrics.flops.get_flops(model, batch_size=1, fpath=params.job_dir / "model_flops.log")
- if params.lr_cycles > 1:
- scheduler = keras.optimizers.schedules.CosineDecayRestarts(
- initial_learning_rate=params.lr_rate,
- first_decay_steps=int(0.1 * params.steps_per_epoch * params.epochs),
- t_mul=1.65 / (0.1 * params.lr_cycles * (params.lr_cycles - 1)),
- m_mul=0.4,
- )
- else:
- scheduler = keras.optimizers.schedules.CosineDecay(
- initial_learning_rate=params.lr_rate,
- decay_steps=params.steps_per_epoch * params.epochs,
- )
- # END IF
+ t_mul = 1
+ first_steps = (params.steps_per_epoch * params.epochs) / (np.power(params.lr_cycles, t_mul) - t_mul + 1)
+ scheduler = keras.optimizers.schedules.CosineDecayRestarts(
+ initial_learning_rate=params.lr_rate,
+ first_decay_steps=np.ceil(first_steps),
+ t_mul=t_mul,
+ m_mul=0.5,
+ )
optimizer = keras.optimizers.Adam(scheduler)
loss = keras.losses.CategoricalFocalCrossentropy(from_logits=True, alpha=class_weights)
@@ -129,7 +106,7 @@ def train(params: HKTrainParams):
keras.metrics.F1Score(name="f1", average="weighted"),
]
- if params.resume and params.weights_file:
+ if params.resume and params.weights_file and params.weights_file.exists():
logger.debug(f"Hydrating model weights from file {params.weights_file}")
model.load_weights(params.weights_file)
@@ -141,7 +118,7 @@ def train(params: HKTrainParams):
logger.debug(f"Model requires {flops/1e6:0.2f} MFLOPS")
ModelCheckpoint = keras.callbacks.ModelCheckpoint
- if env_flag("WANDB"):
+ if nse.utils.env_flag("WANDB"):
ModelCheckpoint = WandbModelCheckpoint
model_callbacks = [
keras.callbacks.EarlyStopping(
@@ -160,22 +137,22 @@ def train(params: HKTrainParams):
),
keras.callbacks.CSVLogger(params.job_dir / "history.csv"),
]
- if env_flag("TENSORBOARD"):
+ if nse.utils.env_flag("TENSORBOARD"):
model_callbacks.append(
keras.callbacks.TensorBoard(
log_dir=params.job_dir,
write_steps_per_second=True,
)
)
- if env_flag("WANDB"):
+ if nse.utils.env_flag("WANDB"):
model_callbacks.append(WandbMetricsLogger())
# NOTE: A bug w/ Keras/TF causes model.fit to hang on last epoch.
# This workaround terminates training on KeyboardInterrupt or last epoch.
- model_callbacks.append(TerminateTrainingCallback())
+ # model_callbacks.append(TerminateTrainingCallback())
try:
- model.fit(
+ history = model.fit(
train_ds,
steps_per_epoch=params.steps_per_epoch,
verbose=params.verbose,
@@ -186,24 +163,33 @@ def train(params: HKTrainParams):
except (KeyboardInterrupt, TermineTrainingError):
logger.warning("Stopping training due to interrupt")
- logger.debug(f"Model saved to {params.model_file}")
-
- # Get full validation results
- model = keras.models.load_model(params.model_file)
- logger.debug("Performing full validation")
- y_pred = np.argmax(model.predict(val_ds), axis=-1)
-
- cm_path = params.job_dir / "confusion_matrix.png"
- nse.plotting.cm.confusion_matrix_plot(y_true, y_pred, labels=class_names, save_path=cm_path, normalize="true")
- if env_flag("WANDB"):
- conf_mat = wandb.plot.confusion_matrix(preds=y_pred, y_true=y_true, class_names=class_names)
- wandb.log({"conf_mat": conf_mat})
- # END IF
-
- # Summarize results
- test_acc = np.sum(y_pred == y_true) / len(y_true)
- test_f1 = f1_score(y_true, y_pred, average="weighted")
- logger.info(f"[VAL SET] ACC={test_acc:.2%}, F1={test_f1:.2%}")
-
- # os.abort()
+
+ logger.debug(f"Model saved to {params.model_file}")
+
+ setup_plotting(dark_theme)
+ if history:
+ nse.plotting.plot_history_metrics(
+ history.history,
+ metrics=["loss", "acc"],
+ save_path=params.job_dir / "history.png",
+ stack=True,
+ figsize=(9, 5),
+ )
+
+ # Get full validation results
+ logger.debug("Performing full validation")
+ y_pred = np.argmax(model.predict(val_ds), axis=-1)
+
+ cm_path = params.job_dir / "confusion_matrix.png"
+ nse.plotting.confusion_matrix_plot(y_true, y_pred, labels=class_names, save_path=cm_path, normalize="true")
+ if nse.utils.env_flag("WANDB"):
+ conf_mat = wandb.plot.confusion_matrix(preds=y_pred, y_true=y_true, class_names=class_names)
+ wandb.log({"conf_mat": conf_mat})
+ # END IF
+
+ # Summarize results
+ rst = model.evaluate(val_ds, return_dict=True)
+ logger.info("[VAL SET] " + ", ".join(f"{k.upper()}={v:.2%}" for k, v in rst.items()))
+
+ # os.abort()
# END TRY
diff --git a/heartkit/tasks/segmentation/__init__.py b/heartkit/tasks/segmentation/__init__.py
index 99613046..b8a7afa3 100644
--- a/heartkit/tasks/segmentation/__init__.py
+++ b/heartkit/tasks/segmentation/__init__.py
@@ -1,4 +1,4 @@
-from ...defines import HKDemoParams, HKExportParams, HKTestParams, HKTrainParams
+from ...defines import HKTaskParams
from ..task import HKTask
from .defines import HKSegment
from .demo import demo
@@ -11,17 +11,17 @@ class SegmentationTask(HKTask):
"""HeartKit Segmentation Task"""
@staticmethod
- def train(params: HKTrainParams):
+ def train(params: HKTaskParams):
train(params)
@staticmethod
- def evaluate(params: HKTestParams):
+ def evaluate(params: HKTaskParams):
evaluate(params)
@staticmethod
- def export(params: HKExportParams):
+ def export(params: HKTaskParams):
export(params)
@staticmethod
- def demo(params: HKDemoParams):
+ def demo(params: HKTaskParams):
demo(params)
diff --git a/heartkit/tasks/segmentation/dataloaders/__init__.py b/heartkit/tasks/segmentation/dataloaders/__init__.py
index 5e446929..7b702755 100644
--- a/heartkit/tasks/segmentation/dataloaders/__init__.py
+++ b/heartkit/tasks/segmentation/dataloaders/__init__.py
@@ -1,7 +1,16 @@
-from .icentia11k import icentia11k_data_generator, icentia11k_label_map
-from .ludb import ludb_data_generator, ludb_label_map
-from .ptbxl import ptbxl_data_generator, ptbxl_label_map
+import neuralspot_edge as nse
-# from .qtdb import qtdb_data_generator
-from .synthetic import synthetic_data_generator, synthetic_label_map
-from .syntheticppg import syntheticppg_data_generator, syntheticppg_label_map
+from ....datasets import HKDataloader
+
+from .icentia11k import Icentia11kDataloader
+from .ludb import LudbDataloader
+from .ptbxl import PtbxlDataloader
+from .ecg_synthetic import EcgSyntheticDataloader
+from .ppg_synthetic import PPgSyntheticDataloader
+
+SegmentationDataloaderFactory = nse.utils.create_factory(factory="HKSegmentationDataloaderFactory", type=HKDataloader)
+SegmentationDataloaderFactory.register("icentia11k", Icentia11kDataloader)
+SegmentationDataloaderFactory.register("ludb", LudbDataloader)
+SegmentationDataloaderFactory.register("ptbxl", PtbxlDataloader)
+SegmentationDataloaderFactory.register("ecg-synthetic", EcgSyntheticDataloader)
+SegmentationDataloaderFactory.register("ppg-synthetic", PPgSyntheticDataloader)
diff --git a/heartkit/tasks/segmentation/dataloaders/ecg_synthetic.py b/heartkit/tasks/segmentation/dataloaders/ecg_synthetic.py
new file mode 100644
index 00000000..52638101
--- /dev/null
+++ b/heartkit/tasks/segmentation/dataloaders/ecg_synthetic.py
@@ -0,0 +1,88 @@
+import random
+from typing import Generator, Iterable
+
+import numpy as np
+import numpy.typing as npt
+import physiokit as pk
+import neuralspot_edge as nse
+
+from ....datasets import EcgSyntheticDataset, HKDataloader
+from ..defines import HKSegment
+
+EcgSyntheticSegmentationMap = {
+ pk.ecg.EcgSegment.tp_overlap: HKSegment.pwave,
+ pk.ecg.EcgSegment.p_wave: HKSegment.pwave,
+ pk.ecg.EcgSegment.qrs_complex: HKSegment.qrs,
+ pk.ecg.EcgSegment.t_wave: HKSegment.twave,
+ pk.ecg.EcgSegment.background: HKSegment.normal,
+ pk.ecg.EcgSegment.u_wave: HKSegment.uwave,
+ pk.ecg.EcgSegment.pr_segment: HKSegment.normal,
+ pk.ecg.EcgSegment.st_segment: HKSegment.normal,
+ pk.ecg.EcgSegment.tp_segment: HKSegment.normal,
+}
+
+
+class EcgSyntheticDataloader(HKDataloader):
+ def __init__(self, ds: EcgSyntheticDataset, **kwargs):
+ super().__init__(ds=ds, **kwargs)
+ # Update label map
+ if self.label_map:
+ self.label_map = {
+ k: self.label_map[v] for (k, v) in EcgSyntheticSegmentationMap.items() if v in self.label_map
+ }
+ # END DEF
+
+ def patient_data_generator(
+ self,
+ patient_id: int,
+ samples_per_patient: int,
+ ):
+ input_size = int(np.ceil((self.ds.sampling_rate / self.sampling_rate) * self.frame_size))
+
+ start_offset = 0
+
+ with self.ds.patient_data(patient_id) as h5:
+ data = h5["data"][:]
+ segs = h5["segmentations"][:]
+ # END WITH
+
+ for _ in range(samples_per_patient):
+ lead = random.choice(self.ds.leads)
+ start = np.random.randint(start_offset, data.shape[1] - input_size)
+ x = data[lead, start : start + input_size].squeeze()
+ x = np.nan_to_num(x).astype(np.float32)
+ x = self.ds.add_noise(x)
+ y = segs[lead, start : start + input_size].squeeze()
+ y = y.astype(np.int32)
+ y = np.vectorize(lambda v: self.label_map.get(v, 0), otypes=[int])(y)
+
+ if self.ds.sampling_rate != self.sampling_rate:
+ ratio = self.sampling_rate / self.ds.sampling_rate
+ x = pk.signal.resample_signal(x, self.ds.sampling_rate, self.sampling_rate, axis=0)
+ x = x[: self.frame_size] # Ensure frame size
+ y_tgt = np.zeros(x.shape, dtype=np.int32)
+ start_idxs = np.hstack((0, np.nonzero(np.diff(y))[0]))
+ end_idxs = np.hstack((start_idxs[1:], y.size))
+ for s, e in zip(start_idxs, end_idxs):
+ y_tgt[int(s * ratio) : int(e * ratio)] = y[s]
+ # END FOR
+ y = y_tgt
+ # END IF
+ x = x.reshape(-1, 1)
+ yield x, y
+ # END FOR
+
+ def data_generator(
+ self,
+ patient_ids: list[int],
+ samples_per_patient: int | list[int],
+ shuffle: bool = False,
+ ) -> Generator[tuple[npt.NDArray, npt.NDArray], None, None]:
+ if isinstance(samples_per_patient, Iterable):
+ samples_per_patient = samples_per_patient[0]
+
+ for pt_id in nse.utils.uniform_id_generator(patient_ids, shuffle=shuffle):
+ for x, y in self.patient_data_generator(pt_id, samples_per_patient):
+ yield x, y
+ # END FOR
+ # END FOR
diff --git a/heartkit/tasks/segmentation/dataloaders/icentia11k.py b/heartkit/tasks/segmentation/dataloaders/icentia11k.py
index 6037aa8c..ab66bf6e 100644
--- a/heartkit/tasks/segmentation/dataloaders/icentia11k.py
+++ b/heartkit/tasks/segmentation/dataloaders/icentia11k.py
@@ -3,62 +3,24 @@
import numpy as np
import numpy.typing as npt
import physiokit as pk
+import neuralspot_edge as nse
-from ....datasets.defines import PatientGenerator
-from ....datasets.icentia11k import IcentiaBeat, IcentiaDataset
+from ....datasets import IcentiaBeat, IcentiaDataset, HKDataloader
from ..defines import HKSegment
-def icentia11k_label_map(
- label_map: dict[int, int] | None = None,
-) -> dict[int, int]:
- """Get label map
+class Icentia11kDataloader(HKDataloader):
+ def __init__(self, ds: IcentiaDataset, **kwargs):
+ super().__init__(ds=ds, **kwargs)
- Args:
- label_map (dict[int, int]|None): Label map
+ def patient_data_generator(
+ self,
+ patient_id: int,
+ samples_per_patient: int,
+ ):
+ input_size = int(np.ceil((self.ds.sampling_rate / self.sampling_rate) * self.frame_size))
- Returns:
- dict[int, int]: Label map
- """
- return label_map
-
-
-def icentia11k_data_generator(
- patient_generator: PatientGenerator,
- ds: IcentiaDataset,
- frame_size: int,
- samples_per_patient: int | list[int] = 1,
- target_rate: int | None = None,
- label_map: dict[int, int] | None = None,
-) -> Generator[tuple[npt.NDArray, npt.NDArray], None, None]:
- """Generate frames w/ segmentation labels (e.g. qrs) using patient generator.
-
- Args:
- patient_generator (PatientGenerator): Patient Generator
- ds: IcentiaDataset
- frame_size (int): Frame size
- samples_per_patient (int | list[int], optional): # samples per patient. Defaults to 1.
- target_rate (int|None, optional): Target rate. Defaults to None.
- label_map (dict[int, int] | None, optional): Label map. Defaults to None.
-
- Returns:
- Generator[tuple[npt.NDArray, npt.NDArray], None, None]: Sample generator
- """
-
- if target_rate is None:
- target_rate = ds.sampling_rate
- # END IF
-
- if isinstance(samples_per_patient, Iterable):
- samples_per_patient = samples_per_patient[0]
-
- tgt_map = label_map # We generate the labels in the generator
-
- input_size = int(np.round((ds.sampling_rate / target_rate) * frame_size))
-
- # For each patient
- for pt in patient_generator:
- with ds.patient_data(pt) as segments:
+ with self.ds.patient_data(patient_id) as segments:
for _ in range(samples_per_patient):
# Randomly pick a segment
seg_key = np.random.choice(list(segments.keys()))
@@ -68,9 +30,10 @@ def icentia11k_data_generator(
# Get data and labels
data = segments[seg_key]["data"][frame_start:frame_end].squeeze()
- if ds.sampling_rate != target_rate:
- ds_ratio = target_rate / ds.sampling_rate
- data = pk.signal.resample_signal(data, ds.sampling_rate, target_rate, axis=0)
+ if self.ds.sampling_rate != self.sampling_rate:
+ ds_ratio = self.sampling_rate / self.ds.sampling_rate
+ data = pk.signal.resample_signal(data, self.ds.sampling_rate, self.sampling_rate, axis=0)
+ data = data[: self.frame_size] # Ensure frame size
else:
ds_ratio = 1
@@ -95,23 +58,13 @@ def icentia11k_data_generator(
# Unclassifiable beat (treat as noise?)
if btype == IcentiaBeat.undefined:
pass
- # noise_lbl = self.class_map.get(HeartSegment.noise.value, -1)
- # # Skip if not in class map
- # if noise_lbl == -1
- # continue
- # # Mark region as noise
- # win_len = max(1, int(0.2 * self.target_rate)) # 200 ms
- # b_left = max(0, bidx - win_len)
- # b_right = min(data.shape[0], bidx + win_len)
- # mask[b_left:b_right] = noise_lbl
-
# Normal, PAC, PVC beat
else:
- qrs_width = int(0.08 * target_rate) # 80 ms
+ qrs_width = int(0.08 * self.sampling_rate) # 80 ms
# Extract QRS segment
qrs = pk.signal.moving_gradient_filter(
data,
- sample_rate=target_rate,
+ sample_rate=self.sampling_rate,
sig_window=0.1,
avg_window=1.0,
sig_prom_weight=1.5,
@@ -125,12 +78,27 @@ def icentia11k_data_generator(
offset = offset[0] if offset.size else win_len
qrs_onset = bidx - onset
qrs_offset = bidx + offset
- mask[qrs_onset:qrs_offset] = tgt_map.get(HKSegment.qrs.value, 0)
+ mask[qrs_onset:qrs_offset] = self.label_map.get(HKSegment.qrs.value, 0)
# END IF
# END FOR
x = np.nan_to_num(data).astype(np.float32)
+ x = x.reshape(-1, 1)
y = mask.astype(np.int32)
yield x, y
# END FOR
# END WITH
- # END FOR
+
+ def data_generator(
+ self,
+ patient_ids: list[int],
+ samples_per_patient: int | list[int],
+ shuffle: bool = False,
+ ) -> Generator[tuple[npt.NDArray, npt.NDArray], None, None]:
+ if isinstance(samples_per_patient, Iterable):
+ samples_per_patient = samples_per_patient[0]
+
+ for pt_id in nse.utils.uniform_id_generator(patient_ids, shuffle=shuffle):
+ for x, y in self.patient_data_generator(pt_id, samples_per_patient):
+ yield x, y
+ # END FOR
+ # END FOR
diff --git a/heartkit/tasks/segmentation/dataloaders/ludb.py b/heartkit/tasks/segmentation/dataloaders/ludb.py
index 4b7f3b5e..9cce52aa 100644
--- a/heartkit/tasks/segmentation/dataloaders/ludb.py
+++ b/heartkit/tasks/segmentation/dataloaders/ludb.py
@@ -1,20 +1,13 @@
import random
-from typing import Generator
+from typing import Generator, Iterable
import numpy as np
import numpy.typing as npt
import physiokit as pk
+import neuralspot_edge as nse
-from ....datasets.defines import PatientGenerator
-from ....datasets.ludb import (
- FID_LOC_IDX,
- SEG_BEG_IDX,
- SEG_END_IDX,
- SEG_LBL_IDX,
- SEG_LEAD_IDX,
- LudbDataset,
- LudbSegmentation,
-)
+from ....datasets import HKDataloader, LudbDataset, LudbSegmentation
+from ....datasets.ludb import FID_LOC_IDX, SEG_BEG_IDX, SEG_END_IDX, SEG_LBL_IDX, SEG_LEAD_IDX
from ..defines import HKSegment
LudbSegmentationMap = {
@@ -25,59 +18,26 @@
}
-def ludb_label_map(
- label_map: dict[int, int] | None = None,
-) -> dict[int, int]:
- """Get label map
-
- Args:
- label_map (dict[int, int]|None): Label map
-
- Returns:
- dict[int, int]: Label map
- """
- return {k: label_map.get(v, -1) for (k, v) in LudbSegmentationMap.items()}
-
-
-def ludb_data_generator(
- patient_generator: PatientGenerator,
- ds: LudbDataset,
- frame_size: int,
- samples_per_patient: int | list[int] = 1,
- target_rate: int | None = None,
- label_map: dict[int, int] | None = None,
-) -> Generator[tuple[npt.NDArray, npt.NDArray], None, None]:
- """Generate frames w/ rhythm labels (e.g. afib) using patient generator.
-
- Args:
- patient_generator (PatientGenerator): Patient Generator
- ds: IcentiaDataset
- frame_size (int): Frame size
- samples_per_patient (int | list[int], optional): # samples per patient. Defaults to 1.
- target_rate (int|None, optional): Target rate. Defaults to None.
- label_map (dict[int, int] | None, optional): Label map. Defaults to None.
-
- Returns:
- Generator[tuple[npt.NDArray, npt.NDArray], None, None]: Sample generator
- """
-
- if target_rate is None:
- target_rate = ds.sampling_rate
- # END IF
-
- # Convert global labels -> ds labels -> class labels (-1 indicates not in class map)
- tgt_map = ludb_label_map(label_map)
-
- for pt in patient_generator:
- with ds.patient_data(pt) as h5:
- data = h5["data"][:]
- segs = h5["segmentations"][:]
- fids = h5["fiducials"][:]
+class LudbDataloader(HKDataloader):
+ def __init__(self, ds: LudbDataset, **kwargs):
+ super().__init__(ds=ds, **kwargs)
+ if self.label_map:
+ self.label_map = {k: self.label_map[v] for (k, v) in LudbSegmentationMap.items() if v in self.label_map}
+
+ def patient_data_generator(
+ self,
+ patient_id: int,
+ samples_per_patient: int,
+ ):
+ with self.ds.patient_data(patient_id) as h5:
+ data = h5["data"][:].copy()
+ segs = h5["segmentations"][:].copy()
+ fids = h5["fiducials"][:].copy()
# END WITH
- if ds.sampling_rate != target_rate:
- ratio = target_rate / ds.sampling_rate
- data = pk.signal.resample_signal(data, ds.sampling_rate, target_rate, axis=0)
+ if self.ds.sampling_rate != self.sampling_rate:
+ ratio = self.sampling_rate / self.ds.sampling_rate
+ data = pk.signal.resample_signal(data, self.ds.sampling_rate, self.sampling_rate, axis=0)
segs[:, (SEG_BEG_IDX, SEG_END_IDX)] = segs[:, (SEG_BEG_IDX, SEG_END_IDX)] * ratio
fids[:, FID_LOC_IDX] = fids[:, FID_LOC_IDX] * ratio
# END IF
@@ -93,13 +53,29 @@ def ludb_data_generator(
stop_offset = max(0, data.shape[0] - segs[-1][SEG_END_IDX] + 100)
for _ in range(samples_per_patient):
# Randomly pick an ECG lead
- lead = random.choice(ds.leads)
+ lead = random.choice(self.ds.leads)
# Randomly select frame within the segment
- frame_start = np.random.randint(start_offset, data.shape[0] - frame_size - stop_offset)
- frame_end = frame_start + frame_size
- x = data[frame_start:frame_end, lead].astype(np.float32)
+ frame_start = np.random.randint(start_offset, data.shape[0] - self.frame_size - stop_offset)
+ frame_end = frame_start + self.frame_size
+ x = data[frame_start:frame_end, lead]
+ x = np.nan_to_num(x, neginf=0, posinf=0).astype(np.float32)
+ x = np.reshape(x, (-1, 1))
y = labels[frame_start:frame_end, lead].astype(np.int32)
- y = np.vectorize(tgt_map.get, otypes=[int])(y)
+ y = np.vectorize(lambda v: self.label_map.get(v, 0), otypes=[int])(y)
yield x, y
# END FOR
- # END FOR
+
+ def data_generator(
+ self,
+ patient_ids: list[int],
+ samples_per_patient: int | list[int],
+ shuffle: bool = False,
+ ) -> Generator[tuple[npt.NDArray, npt.NDArray], None, None]:
+ if isinstance(samples_per_patient, Iterable):
+ samples_per_patient = samples_per_patient[0]
+
+ for pt_id in nse.utils.uniform_id_generator(patient_ids, shuffle=shuffle):
+ for x, y in self.patient_data_generator(pt_id, samples_per_patient):
+ yield x, y
+ # END FOR
+ # END FOR
diff --git a/heartkit/tasks/segmentation/dataloaders/ppg_synthetic.py b/heartkit/tasks/segmentation/dataloaders/ppg_synthetic.py
new file mode 100644
index 00000000..08f5cc28
--- /dev/null
+++ b/heartkit/tasks/segmentation/dataloaders/ppg_synthetic.py
@@ -0,0 +1,80 @@
+from typing import Generator, Iterable
+
+import numpy as np
+import numpy.typing as npt
+import physiokit as pk
+import neuralspot_edge as nse
+
+from ....datasets import PpgSyntheticDataset, HKDataloader
+from ..defines import HKSegment
+
+PpgSyntheticSegmentationMap = {
+ pk.ppg.PpgSegment.background: HKSegment.normal,
+ pk.ppg.PpgSegment.systolic: HKSegment.systolic,
+ pk.ppg.PpgSegment.diastolic: HKSegment.diastolic,
+}
+
+
+class PPgSyntheticDataloader(HKDataloader):
+ def __init__(self, ds: PpgSyntheticDataset, **kwargs):
+ super().__init__(ds=ds, **kwargs)
+ # Update label map
+ if self.label_map:
+ self.label_map = {
+ k: self.label_map[v] for (k, v) in PpgSyntheticSegmentationMap.items() if v in self.label_map
+ }
+ # END DEF
+
+ def patient_data_generator(
+ self,
+ patient_id: int,
+ samples_per_patient: int,
+ ):
+ input_size = int(np.ceil((self.ds.sampling_rate / self.sampling_rate) * self.frame_size))
+
+ start_offset = 0
+
+ with self.ds.patient_data(patient_id) as h5:
+ data = h5["data"][:]
+ segs = h5["segmentations"][:]
+ # END WITH
+
+ for _ in range(samples_per_patient):
+ start = np.random.randint(start_offset, data.shape[0] - input_size)
+ x = data[start : start + input_size].squeeze()
+ x = np.nan_to_num(x).astype(np.float32)
+ x = self.ds.add_noise(x)
+ y = segs[start : start + input_size].squeeze()
+ y = y.astype(np.int32)
+ y = np.vectorize(lambda v: self.label_map.get(v, 0), otypes=[int])(y)
+
+ if self.ds.sampling_rate != self.sampling_rate:
+ ratio = self.sampling_rate / self.ds.sampling_rate
+ x = pk.signal.resample_signal(x, self.ds.sampling_rate, self.sampling_rate, axis=0)
+ x = x[: self.frame_size] # Ensure frame size
+ y_tgt = np.zeros(x.shape, dtype=np.int32)
+ start_idxs = np.hstack((0, np.nonzero(np.diff(y))[0]))
+ end_idxs = np.hstack((start_idxs[1:], y.size))
+ for s, e in zip(start_idxs, end_idxs):
+ y_tgt[int(s * ratio) : int(e * ratio)] = y[s]
+ # END FOR
+ y = y_tgt
+ # END IF
+ x = x.reshape(-1, 1)
+ yield x, y
+ # END FOR
+
+ def data_generator(
+ self,
+ patient_ids: list[int],
+ samples_per_patient: int | list[int],
+ shuffle: bool = False,
+ ) -> Generator[tuple[npt.NDArray, npt.NDArray], None, None]:
+ if isinstance(samples_per_patient, Iterable):
+ samples_per_patient = samples_per_patient[0]
+
+ for pt_id in nse.utils.uniform_id_generator(patient_ids, shuffle=shuffle):
+ for x, y in self.patient_data_generator(pt_id, samples_per_patient):
+ yield x, y
+ # END FOR
+ # END FOR
diff --git a/heartkit/tasks/segmentation/dataloaders/ptbxl.py b/heartkit/tasks/segmentation/dataloaders/ptbxl.py
index 271b0d40..4873a141 100644
--- a/heartkit/tasks/segmentation/dataloaders/ptbxl.py
+++ b/heartkit/tasks/segmentation/dataloaders/ptbxl.py
@@ -4,64 +4,24 @@
import numpy as np
import numpy.typing as npt
import physiokit as pk
+import neuralspot_edge as nse
-from ....datasets.defines import PatientGenerator
-from ....datasets.ptbxl import PtbxlDataset
+from ....datasets import HKDataloader, PtbxlDataset
from ..defines import HKSegment
-def ptbxl_label_map(
- label_map: dict[int, int] | None = None,
-) -> dict[int, int]:
- """Get label map
+class PtbxlDataloader(HKDataloader):
+ def __init__(self, ds: PtbxlDataset, **kwargs):
+ super().__init__(ds=ds, **kwargs)
- Args:
- label_map (dict[int, int]|None): Label map
+ def patient_data_generator(
+ self,
+ patient_id: int,
+ samples_per_patient: int,
+ ):
+ input_size = int(np.ceil((self.ds.sampling_rate / self.sampling_rate) * self.frame_size))
- Returns:
- dict[int, int]: Label map
- """
-
- # We generate the labels in the generator
- return label_map
-
-
-def ptbxl_data_generator(
- patient_generator: PatientGenerator,
- ds: PtbxlDataset,
- frame_size: int,
- samples_per_patient: int | list[int] = 1,
- target_rate: int | None = None,
- label_map: dict[int, int] | None = None,
-) -> Generator[tuple[npt.NDArray, npt.NDArray], None, None]:
- """Generate frames w/ segmentation labels (e.g. qrs) using patient generator.
-
- Args:
- patient_generator (PatientGenerator): Patient Generator
- ds: PtbxlDataset
- frame_size (int): Frame size
- samples_per_patient (int | list[int], optional): # samples per patient. Defaults to 1.
- target_rate (int|None, optional): Target rate. Defaults to None.
- label_map (dict[int, int] | None, optional): Label map. Defaults to None.
-
- Returns:
- Generator[tuple[npt.NDArray, npt.NDArray], None, None]: Sample generator
- """
-
- if target_rate is None:
- target_rate = ds.sampling_rate
- # END IF
-
- if isinstance(samples_per_patient, Iterable):
- samples_per_patient = samples_per_patient[0]
-
- tgt_map = ptbxl_label_map(label_map=label_map)
-
- input_size = int(np.round((ds.sampling_rate / target_rate) * frame_size))
-
- # For each patient
- for pt in patient_generator:
- with ds.patient_data(pt) as h5:
+ with self.ds.patient_data(patient_id) as h5:
data = h5["data"][:]
blabels = h5["blabels"][:]
# END WITH
@@ -70,16 +30,18 @@ def ptbxl_data_generator(
blabels[:, 0] = blabels[:, 0] * 5
for _ in range(samples_per_patient):
# Select random lead and start index
- lead = random.choice(ds.leads)
+ lead = random.choice(self.ds.leads)
frame_start = np.random.randint(0, data.shape[1] - input_size)
frame_end = frame_start + input_size
frame_blabels = blabels[(blabels[:, 0] >= frame_start) & (blabels[:, 0] < frame_end)]
x = data[lead, frame_start:frame_end].copy()
- if ds.sampling_rate != target_rate:
- ds_ratio = target_rate / ds.sampling_rate
- x = pk.signal.resample_signal(x, ds.sampling_rate, target_rate, axis=0)
+ if self.ds.sampling_rate != self.sampling_rate:
+ ds_ratio = self.sampling_rate / self.ds.sampling_rate
+ x = pk.signal.resample_signal(x, self.ds.sampling_rate, self.sampling_rate, axis=0)
+ x = x[: self.frame_size] # Ensure frame size
else:
ds_ratio = 1
+
# Create segment mask
mask = np.zeros_like(x, dtype=np.int32)
@@ -99,20 +61,35 @@ def ptbxl_data_generator(
# Extract QRS segment
qrs = pk.signal.moving_gradient_filter(
- x, sample_rate=target_rate, sig_window=0.1, avg_window=1.0, sig_prom_weight=1.5
+ x, sample_rate=self.sampling_rate, sig_window=0.1, avg_window=1.0, sig_prom_weight=1.5
)
- win_len = max(1, int(0.08 * target_rate)) # 80 ms
+ win_len = max(1, int(0.08 * self.sampling_rate)) # 80 ms
b_left = max(0, bidx - win_len)
b_right = min(x.shape[0], bidx + win_len)
onset = np.where(np.flip(qrs[b_left:bidx]) < 0)[0]
onset = onset[0] if onset.size else win_len
offset = np.where(qrs[bidx + 1 : b_right] < 0)[0]
offset = offset[0] if offset.size else win_len
- mask[bidx - onset : bidx + offset] = tgt_map.get(HKSegment.qrs.value, 0)
+ mask[bidx - onset : bidx + offset] = self.label_map.get(HKSegment.qrs.value, 0)
# END IF
# END FOR
x = np.nan_to_num(x).astype(np.float32)
+ x = x.reshape(-1, 1)
y = mask.astype(np.int32)
yield x, y
# END FOR
- # END FOR
+
+ def data_generator(
+ self,
+ patient_ids: list[int],
+ samples_per_patient: int | list[int],
+ shuffle: bool = False,
+ ) -> Generator[tuple[npt.NDArray, npt.NDArray], None, None]:
+ if isinstance(samples_per_patient, Iterable):
+ samples_per_patient = samples_per_patient[0]
+
+ for pt_id in nse.utils.uniform_id_generator(patient_ids, shuffle=shuffle):
+ for x, y in self.patient_data_generator(pt_id, samples_per_patient):
+ yield x, y
+ # END FOR
+ # END FOR
diff --git a/heartkit/tasks/segmentation/dataloaders/qtdb.py b/heartkit/tasks/segmentation/dataloaders/qtdb.py
deleted file mode 100644
index d969850c..00000000
--- a/heartkit/tasks/segmentation/dataloaders/qtdb.py
+++ /dev/null
@@ -1,49 +0,0 @@
-# def segmentation_generator(
-# self,
-# patient_generator: PatientGenerator,
-# samples_per_patient: int | list[int] = 1,
-# ) -> SampleGenerator:
-# """Generate frames and segment labels.
-
-# Args:
-# patient_generator (PatientGenerator): Patient Generator
-# samples_per_patient (int | list[int], optional): # samples per patient. Defaults to 1.
-# Returns:
-# SampleGenerator: Sample generator
-# Yields:
-# Iterator[SampleGenerator]
-# """
-
-# for _, pt in patient_generator:
-# # NOTE: [:] will load all data into RAM- ideal for small dataset
-# data = pt["data"][:]
-# segs = pt["segmentations"][:]
-# fids = pt["fiducials"][:]
-
-# if self.sampling_rate != self.target_rate:
-# ratio = self.target_rate / self.sampling_rate
-# data = pk.signal.resample_signal(data, self.sampling_rate, self.target_rate, axis=0)
-# segs[:, (SEG_BEG_IDX, SEG_END_IDX)] = segs[:, (SEG_BEG_IDX, SEG_END_IDX)] * ratio
-# fids[:, FID_LOC_IDX] = fids[:, FID_LOC_IDX] * ratio
-# # END IF
-
-# # Create segmentation mask
-# labels = np.zeros_like(data)
-# for seg_idx in range(segs.shape[0]):
-# seg = segs[seg_idx]
-# labels[seg[SEG_BEG_IDX] : seg[SEG_END_IDX], seg[SEG_LEAD_IDX]] = seg[SEG_LBL_IDX]
-# # END FOR
-
-# start_offset = max(0, segs[0][SEG_BEG_IDX] - 100)
-# stop_offset = max(0, data.shape[0] - segs[-1][SEG_END_IDX] + 100)
-# for _ in range(samples_per_patient):
-# # Randomly pick an ECG lead
-# lead = np.random.randint(data.shape[1])
-# # Randomly select frame within the segment
-# frame_start = np.random.randint(start_offset, data.shape[0] - self.frame_size - stop_offset)
-# frame_end = frame_start + self.frame_size
-# x = data[frame_start:frame_end, lead].astype(np.float32).reshape((self.frame_size,))
-# y = labels[frame_start:frame_end, lead].astype(np.int32)
-# yield x, y
-# # END FOR
-# # END FOR
diff --git a/heartkit/tasks/segmentation/dataloaders/synthetic.py b/heartkit/tasks/segmentation/dataloaders/synthetic.py
deleted file mode 100644
index 3b253f89..00000000
--- a/heartkit/tasks/segmentation/dataloaders/synthetic.py
+++ /dev/null
@@ -1,104 +0,0 @@
-import random
-from typing import Generator
-
-import numpy as np
-import numpy.typing as npt
-import physiokit as pk
-
-from ....datasets.defines import PatientGenerator
-from ....datasets.synthetic import SyntheticDataset
-from ..defines import HKSegment
-
-SyntheticSegmentationMap = {
- pk.ecg.EcgSegment.tp_overlap: HKSegment.pwave,
- pk.ecg.EcgSegment.p_wave: HKSegment.pwave,
- pk.ecg.EcgSegment.qrs_complex: HKSegment.qrs,
- pk.ecg.EcgSegment.t_wave: HKSegment.twave,
- pk.ecg.EcgSegment.background: HKSegment.normal,
- pk.ecg.EcgSegment.u_wave: HKSegment.uwave,
- pk.ecg.EcgSegment.pr_segment: HKSegment.normal,
- pk.ecg.EcgSegment.st_segment: HKSegment.normal,
- pk.ecg.EcgSegment.tp_segment: HKSegment.normal,
-}
-
-
-def synthetic_label_map(
- label_map: dict[int, int] | None = None,
-) -> dict[int, int]:
- """Get label map
-
- Args:
- label_map (dict[int, int]|None): Label map
-
- Returns:
- dict[int, int]: Label map
- """
- return {k: label_map[v] for (k, v) in SyntheticSegmentationMap.items() if v in label_map}
- # return {k: label_map.get(v, -1) for (k, v) in SyntheticSegmentationMap.items()}
-
-
-def synthetic_data_generator(
- patient_generator: PatientGenerator,
- ds: SyntheticDataset,
- frame_size: int,
- samples_per_patient: int | list[int] = 1,
- target_rate: int | None = None,
- label_map: dict[int, int] | None = None,
-) -> Generator[tuple[npt.NDArray, npt.NDArray], None, None]:
- """Generate frames w/ rhythm labels (e.g. afib) using patient generator.
-
- Args:
- patient_generator (PatientGenerator): Patient Generator
- ds: IcentiaDataset
- frame_size (int): Frame size
- samples_per_patient (int | list[int], optional): # samples per patient. Defaults to 1.
- target_rate (int|None, optional): Target rate. Defaults to None.
- label_map (dict[int, int] | None, optional): Label map. Defaults to None.
-
- Returns:
- Generator[tuple[npt.NDArray, npt.NDArray], None, None]: Sample generator
- """
-
- if target_rate is None:
- target_rate = ds.sampling_rate
- # END IF
-
- input_size = int(np.round((ds.sampling_rate / target_rate) * frame_size))
-
- tgt_map = synthetic_label_map(label_map)
-
- start_offset = 0
-
- for pt in patient_generator:
- with ds.patient_data(pt) as h5:
- data = h5["data"][:]
- segs = h5["segmentations"][:]
- # END WITH
-
- for _ in range(samples_per_patient):
- lead = random.choice(ds.leads)
- start = np.random.randint(start_offset, data.shape[1] - input_size)
- x = data[lead, start : start + input_size].squeeze()
- x = np.nan_to_num(x).astype(np.float32)
- x = ds.add_noise(x)
- y = segs[lead, start : start + input_size].squeeze()
- y = y.astype(np.int32)
- y = np.vectorize(lambda v: tgt_map.get(v, 0), otypes=[int])(y)
-
- if ds.sampling_rate != target_rate:
- ratio = target_rate / ds.sampling_rate
- x = pk.signal.resample_signal(x, ds.sampling_rate, target_rate, axis=0)
- y_tgt = np.zeros(x.shape, dtype=np.int32)
- start_idxs = np.hstack((0, np.nonzero(np.diff(y))[0]))
- end_idxs = np.hstack((start_idxs[1:], y.size))
- for s, e in zip(start_idxs, end_idxs):
- y_tgt[int(s * ratio) : int(e * ratio)] = y[s]
- # END FOR
- y = y_tgt
- # NOTE: resample_categorical is not working
- # y = pk.signal.filter.resample_categorical(y, ds.sampling_rate, target_rate, axis=0)
-
- # END IF
- yield x, y
- # END FOR
- # END FOR
diff --git a/heartkit/tasks/segmentation/dataloaders/syntheticppg.py b/heartkit/tasks/segmentation/dataloaders/syntheticppg.py
deleted file mode 100644
index 137bec1b..00000000
--- a/heartkit/tasks/segmentation/dataloaders/syntheticppg.py
+++ /dev/null
@@ -1,92 +0,0 @@
-from typing import Generator
-
-import numpy as np
-import numpy.typing as npt
-import physiokit as pk
-
-from ....datasets.defines import PatientGenerator
-from ....datasets.syntheticppg import SyntheticPpgDataset
-from ..defines import HKSegment
-
-SyntheticPpgSegmentationMap = {
- pk.ppg.PpgSegment.background: HKSegment.normal,
- pk.ppg.PpgSegment.systolic: HKSegment.systolic,
- pk.ppg.PpgSegment.diastolic: HKSegment.diastolic,
-}
-
-
-def syntheticppg_label_map(
- label_map: dict[int, int] | None = None,
-) -> dict[int, int]:
- """Get label map
-
- Args:
- label_map (dict[int, int]|None): Label map
-
- Returns:
- dict[int, int]: Label map
- """
- return {k: label_map[v] for (k, v) in SyntheticPpgSegmentationMap.items() if v in label_map}
-
-
-def syntheticppg_data_generator(
- patient_generator: PatientGenerator,
- ds: SyntheticPpgDataset,
- frame_size: int,
- samples_per_patient: int | list[int] = 1,
- target_rate: int | None = None,
- label_map: dict[int, int] | None = None,
-) -> Generator[tuple[npt.NDArray, npt.NDArray], None, None]:
- """Generate frames w/ rhythm labels (e.g. afib) using patient generator.
-
- Args:
- patient_generator (PatientGenerator): Patient Generator
- ds: IcentiaDataset
- frame_size (int): Frame size
- samples_per_patient (int | list[int], optional): # samples per patient. Defaults to 1.
- target_rate (int|None, optional): Target rate. Defaults to None.
- label_map (dict[int, int] | None, optional): Label map. Defaults to None.
-
- Returns:
- Generator[tuple[npt.NDArray, npt.NDArray], None, None]: Sample generator
- """
-
- if target_rate is None:
- target_rate = ds.sampling_rate
- # END IF
-
- input_size = int(np.round((ds.sampling_rate / target_rate) * frame_size))
-
- tgt_map = syntheticppg_label_map(label_map)
-
- start_offset = 0
-
- for pt in patient_generator:
- with ds.patient_data(pt) as h5:
- data = h5["data"][:]
- segs = h5["segmentations"][:]
- # END WITH
-
- for _ in range(samples_per_patient):
- start = np.random.randint(start_offset, data.shape[0] - input_size)
- x = data[start : start + input_size].squeeze()
- x = np.nan_to_num(x).astype(np.float32)
- x = ds.add_noise(x)
- y = segs[start : start + input_size].squeeze()
- y = y.astype(np.int32)
- y = np.vectorize(lambda v: tgt_map.get(v, 0), otypes=[int])(y)
-
- if ds.sampling_rate != target_rate:
- ratio = target_rate / ds.sampling_rate
- x = pk.signal.resample_signal(x, ds.sampling_rate, target_rate, axis=0)
- y_tgt = np.zeros(x.shape, dtype=np.int32)
- start_idxs = np.hstack((0, np.nonzero(np.diff(y))[0]))
- end_idxs = np.hstack((start_idxs[1:], y.size))
- for s, e in zip(start_idxs, end_idxs):
- y_tgt[int(s * ratio) : int(e * ratio)] = y[s]
- # END FOR
- y = y_tgt
- # END IF
- yield x, y
- # END FOR
- # END FOR
diff --git a/heartkit/tasks/segmentation/datasets.py b/heartkit/tasks/segmentation/datasets.py
index 89119446..72ce19b0 100644
--- a/heartkit/tasks/segmentation/datasets.py
+++ b/heartkit/tasks/segmentation/datasets.py
@@ -1,352 +1,166 @@
-import functools
-import logging
-from pathlib import Path
-
import numpy as np
-import numpy.typing as npt
import tensorflow as tf
+import neuralspot_edge as nse
from ...datasets import (
HKDataset,
- augment_pipeline,
- preprocess_pipeline,
- uniform_id_generator,
-)
-from ...datasets.dataloader import test_dataloader, train_val_dataloader
-from ...defines import (
- AugmentationParams,
- HKExportParams,
- HKTestParams,
- HKTrainParams,
- PreprocessParams,
-)
-from ...utils import resolve_template_path
-from .dataloaders import (
- icentia11k_data_generator,
- icentia11k_label_map,
- ludb_data_generator,
- ludb_label_map,
- ptbxl_data_generator,
- ptbxl_label_map,
- synthetic_data_generator,
- synthetic_label_map,
- syntheticppg_data_generator,
- syntheticppg_label_map,
+ create_augmentation_pipeline,
)
+from ...datasets.dataloader import HKDataloader
+from ...defines import HKTaskParams, NamedParams
-logger = logging.getLogger(__name__)
-
-
-def preprocess(x: npt.NDArray, preprocesses: list[PreprocessParams], sample_rate: float) -> npt.NDArray:
- """Preprocess data pipeline
-
- Args:
- x (npt.NDArray): Input data
- preprocesses (list[PreprocessParams]): Preprocess parameters
- sample_rate (float): Sample rate
-
- Returns:
- npt.NDArray: Preprocessed data
- """
- return preprocess_pipeline(x, preprocesses=preprocesses, sample_rate=sample_rate)
-
-
-def augment(x: npt.NDArray, augmentations: list[AugmentationParams], sample_rate: float) -> npt.NDArray:
- """Augment data pipeline
-
- Args:
- x (npt.NDArray): Input data
- augmentations (list[AugmentationParams]): Augmentation parameters
- sample_rate (float): Sample rate
-
- Returns:
- npt.NDArray: Augmented data
- """
- return augment_pipeline(
- x=x,
- augmentations=augmentations,
- sample_rate=sample_rate,
- )
-
-
-def prepare(
- x_y: tuple[npt.NDArray, npt.NDArray],
- sample_rate: float,
- preprocesses: list[PreprocessParams],
- augmentations: list[AugmentationParams],
- spec: tuple[tf.TensorSpec, tf.TensorSpec],
- num_classes: int,
-) -> tuple[npt.NDArray, npt.NDArray]:
- """Prepare dataset
-
- Args:
- x_y (tuple[npt.NDArray, int]): Input data and label
- sample_rate (float): Sample rate
- preprocesses (list[PreprocessParams]|None): Preprocess parameters
- augmentations (list[AugmentationParams]|None): Augmentation parameters
- spec (tuple[tf.TensorSpec, tf.TensorSpec]): TensorSpec
- num_classes (int): Number of classes
-
- Returns:
- tuple[npt.NDArray, npt.NDArray]: Data and label
- """
- x, y = x_y[0].copy(), x_y[1]
-
- if augmentations:
- x = augment(x, augmentations, sample_rate)
- # END IF
-
- if preprocesses:
- x = preprocess(x, preprocesses, sample_rate)
- # END IF
-
- x = x.reshape(spec[0].shape)
- y = tf.one_hot(y, num_classes)
-
- return x, y
-
+from .dataloaders import SegmentationDataloaderFactory as DataloaderFactory
-def get_ds_label_map(ds: HKDataset, label_map: dict[int, int] | None = None) -> dict[int, int]:
- """Get label map for dataset
+logger = nse.utils.setup_logger(__name__)
- Args:
- ds (HKDataset): Dataset
- label_map (dict[int, int]|None): Label map
- Returns:
- dict[int, int]: Label map
- """
- match ds.name:
- case "icentia11k":
- return icentia11k_label_map(label_map=label_map)
- case "ludb":
- return ludb_label_map(label_map=label_map)
- case "ptbxl":
- return ptbxl_label_map(label_map=label_map)
- case "synthetic":
- return synthetic_label_map(label_map=label_map)
- case "syntheticppg":
- return syntheticppg_label_map(label_map=label_map)
- case _:
- raise ValueError(f"Dataset {ds.name} not supported")
- # END MATCH
-
-
-def get_data_generator(
- ds: HKDataset,
- frame_size: int,
- samples_per_patient: int,
- target_rate: int,
- label_map: dict[int, int] | None = None,
+def create_data_pipeline(
+ ds: tf.data.Dataset,
+ sampling_rate: int,
+ batch_size: int,
+ buffer_size: int | None = None,
+ augmentations: list[NamedParams] | None = None,
+ num_classes: int = 2,
):
- """Get task data generator for dataset
-
- Args:
- ds (HKDataset): Dataset
- frame_size (int): Frame size
- samples_per_patient (int): Samples per patient
- target_rate (int): Target rate
- label_map (dict[int, int]|None): Label map
-
- Returns:
- callable: Data generator
- """
- match ds.name:
- case "icentia11k":
- data_generator = icentia11k_data_generator
- case "ludb":
- data_generator = ludb_data_generator
- case "ptbxl":
- data_generator = ptbxl_data_generator
- case "synthetic":
- data_generator = synthetic_data_generator
- case "syntheticppg":
- data_generator = syntheticppg_data_generator
- case _:
- raise ValueError(f"Dataset {ds.name} not supported")
- # END MATCH
- return functools.partial(
- data_generator,
- ds=ds,
- frame_size=frame_size,
- samples_per_patient=samples_per_patient,
- target_rate=target_rate,
- label_map=label_map,
+ if buffer_size:
+ ds = ds.shuffle(
+ buffer_size=buffer_size,
+ reshuffle_each_iteration=True,
+ )
+ if batch_size:
+ ds = ds.batch(
+ batch_size=batch_size,
+ drop_remainder=True,
+ num_parallel_calls=tf.data.AUTOTUNE,
+ )
+ augmenter = create_augmentation_pipeline(
+ augmentations,
+ sampling_rate=sampling_rate,
)
-
-
-def resolve_ds_cache_path(fpath: Path | None, ds: HKDataset, task: str, frame_size: int, sample_rate: int):
- """Resolve dataset cache path
-
- Args:
- fpath (Path|None): File path
- ds (HKDataset): Dataset
- task (str): Task
- frame_size (int): Frame size
- sample_rate (int): Sampling rate
-
- Returns:
- Path|None: Resolved path
- """
- if not fpath:
- return None
- return resolve_template_path(
- fpath=fpath,
- dataset=ds.name,
- task=task,
- frame_size=frame_size,
- sampling_rate=sample_rate,
+ ds = (
+ ds.map(
+ lambda data, labels: {
+ "data": tf.cast(data, "float32"),
+ "labels": tf.one_hot(labels, num_classes),
+ },
+ num_parallel_calls=tf.data.AUTOTUNE,
+ )
+ .map(
+ augmenter,
+ num_parallel_calls=tf.data.AUTOTUNE,
+ )
+ .map(
+ lambda data: (data["data"], data["labels"]),
+ num_parallel_calls=tf.data.AUTOTUNE,
+ )
)
+ return ds.prefetch(tf.data.AUTOTUNE)
+
def load_train_datasets(
datasets: list[HKDataset],
- params: HKTrainParams,
- ds_spec: tuple[tf.TensorSpec, tf.TensorSpec],
+ params: HKTaskParams,
) -> tuple[tf.data.Dataset, tf.data.Dataset]:
- """Load training and validation datasets
-
- Args:
- datasets (list[HKDataset]): Datasets
- params (HKTrainParams): Training parameters
- ds_spec (tuple[tf.TensorSpec, tf.TensorSpec]): TensorSpec
-
- Returns:
- tuple[tf.data.Dataset, tf.data.Dataset]: Train and validation datasets
- """
- id_generator = functools.partial(uniform_id_generator, repeat=True)
- train_prepare = functools.partial(
- prepare,
- sample_rate=params.sampling_rate,
- preprocesses=params.preprocesses,
- augmentations=params.augmentations,
- spec=ds_spec,
- num_classes=params.num_classes,
- )
-
train_datasets = []
val_datasets = []
for ds in datasets:
- val_file = resolve_ds_cache_path(
- params.val_file,
- ds=ds,
- task="segmentation",
- frame_size=params.frame_size,
- sample_rate=params.sampling_rate,
- )
- data_generator = get_data_generator(
+ dataloader: HKDataloader = DataloaderFactory.get(ds.name)(
ds=ds,
frame_size=params.frame_size,
- samples_per_patient=params.samples_per_patient,
- target_rate=params.sampling_rate,
+ sampling_rate=params.sampling_rate,
label_map=params.class_map,
)
- train_ds, val_ds = train_val_dataloader(
- ds=ds,
- spec=ds_spec,
- data_generator=data_generator,
- id_generator=id_generator,
+ train_patients, val_patients = dataloader.split_train_val_patients(
train_patients=params.train_patients,
val_patients=params.val_patients,
- val_pt_samples=params.val_samples_per_patient,
- val_file=val_file,
- val_size=params.val_size,
- label_map=params.class_map,
- label_type=None,
- preprocess=train_prepare,
- num_workers=params.data_parallelism,
+ )
+
+ train_ds = dataloader.create_dataloader(
+ patient_ids=train_patients, samples_per_patient=params.samples_per_patient, shuffle=True
+ )
+
+ val_ds = dataloader.create_dataloader(
+ patient_ids=val_patients, samples_per_patient=params.val_samples_per_patient, shuffle=False
)
train_datasets.append(train_ds)
val_datasets.append(val_ds)
# END FOR
- ds_weights = np.array([d.weight for d in params.datasets])
- ds_weights = ds_weights / ds_weights.sum()
+ ds_weights = None
+ if params.dataset_weights:
+ ds_weights = np.array(params.dataset_weights)
+ ds_weights = ds_weights / ds_weights.sum()
train_ds = tf.data.Dataset.sample_from_datasets(train_datasets, weights=ds_weights)
val_ds = tf.data.Dataset.sample_from_datasets(val_datasets, weights=ds_weights)
# Shuffle and batch datasets for training
- train_ds = (
- train_ds.shuffle(
- buffer_size=params.buffer_size,
- reshuffle_each_iteration=True,
- )
- .batch(
- batch_size=params.batch_size,
- drop_remainder=False,
- num_parallel_calls=tf.data.AUTOTUNE,
- )
- .prefetch(buffer_size=tf.data.AUTOTUNE)
+ train_ds = create_data_pipeline(
+ ds=train_ds,
+ sampling_rate=params.sampling_rate,
+ batch_size=params.batch_size,
+ buffer_size=params.buffer_size,
+ augmentations=params.augmentations + params.preprocesses,
+ num_classes=params.num_classes,
)
- val_ds = val_ds.batch(
+
+ val_ds = create_data_pipeline(
+ ds=val_ds,
+ sampling_rate=params.sampling_rate,
batch_size=params.batch_size,
- drop_remainder=True,
- num_parallel_calls=tf.data.AUTOTUNE,
+ buffer_size=None,
+ augmentations=params.augmentations + params.preprocesses,
+ num_classes=params.num_classes,
)
+
+ # If given fixed val size or steps, then capture and cache
+ val_steps_per_epoch = params.val_size // params.batch_size if params.val_size else params.val_steps_per_epoch
+ if val_steps_per_epoch:
+ logger.info(f"Validation steps per epoch: {val_steps_per_epoch}")
+ val_ds = val_ds.take(val_steps_per_epoch).cache()
+
return train_ds, val_ds
def load_test_dataset(
datasets: list[HKDataset],
- params: HKTestParams | HKExportParams,
- ds_spec: tuple[tf.TensorSpec, tf.TensorSpec],
+ params: HKTaskParams,
) -> tf.data.Dataset:
- """Load test dataset
-
- Args:
- datasets (list[HKDataset]): Datasets
- params (HKTestParams|HKExportParams): Test parameters
- ds_spec (tuple[tf.TensorSpec, tf.TensorSpec]): TensorSpec
-
- Returns:
- tf.data.Dataset: Test dataset
- """
-
- id_generator = functools.partial(uniform_id_generator, repeat=True)
- test_prepare = functools.partial(
- prepare,
- sample_rate=params.sampling_rate,
- preprocesses=params.preprocesses,
- augmentations=None, # params.augmentations,
- spec=ds_spec,
- num_classes=params.num_classes,
- )
test_datasets = []
for ds in datasets:
- test_file = resolve_ds_cache_path(
- fpath=params.test_file,
- ds=ds,
- task="segmentation",
- frame_size=params.frame_size,
- sample_rate=params.sampling_rate,
- )
- data_generator = get_data_generator(
+ dataloader: HKDataloader = DataloaderFactory.get(ds.name)(
ds=ds,
frame_size=params.frame_size,
- samples_per_patient=params.test_samples_per_patient,
- target_rate=params.sampling_rate,
+ sampling_rate=params.sampling_rate,
label_map=params.class_map,
)
- test_ds = test_dataloader(
- ds=ds,
- spec=ds_spec,
- data_generator=data_generator,
- id_generator=id_generator,
- test_patients=params.test_patients,
- test_file=test_file,
- label_map=params.class_map,
- label_type=None,
- preprocess=test_prepare,
- num_workers=params.data_parallelism,
+ test_patients = dataloader.test_patient_ids(params.test_patients)
+ test_ds = dataloader.create_dataloader(
+ patient_ids=test_patients,
+ samples_per_patient=params.test_samples_per_patient,
+ shuffle=False,
)
test_datasets.append(test_ds)
# END FOR
- ds_weights = np.array([d.weight for d in params.datasets])
- ds_weights = ds_weights / ds_weights.sum()
+ ds_weights = None
+ if params.dataset_weights:
+ ds_weights = np.array(params.dataset_weights)
+ ds_weights = ds_weights / ds_weights.sum()
test_ds = tf.data.Dataset.sample_from_datasets(test_datasets, weights=ds_weights)
- # END WITH
+ test_ds = create_data_pipeline(
+ ds=test_ds,
+ sampling_rate=params.sampling_rate,
+ batch_size=params.batch_size,
+ augmentations=params.augmentations + params.preprocesses,
+ num_classes=params.num_classes,
+ )
+
+ if params.test_size:
+ batch_size = getattr(params, "batch_size", 1)
+ test_ds = test_ds.take(params.test_size // batch_size).cache()
+
return test_ds
diff --git a/heartkit/tasks/segmentation/demo.py b/heartkit/tasks/segmentation/demo.py
index 41c752ab..8e4be0ba 100644
--- a/heartkit/tasks/segmentation/demo.py
+++ b/heartkit/tasks/segmentation/demo.py
@@ -5,23 +5,21 @@
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from tqdm import tqdm
+import neuralspot_edge as nse
-from ...datasets.utils import uniform_id_generator
-from ...defines import HKDemoParams
+from ...defines import HKTaskParams
from ...rpc import BackendFactory
-from ...utils import setup_logger
-from ..utils import load_datasets
-from .datasets import augment, preprocess
+from ...datasets import DatasetFactory, create_augmentation_pipeline
from .defines import HKSegment
-def demo(params: HKDemoParams):
+def demo(params: HKTaskParams):
"""Run segmentation demo.
Args:
- params (HKDemoParams): Demo parameters
+ params (HKTaskParams): Demo parameters
"""
- logger = setup_logger(__name__, level=params.verbose)
+ logger = nse.utils.setup_logger(__name__, level=params.verbose)
bg_color = "rgba(38,42,50,1.0)"
primary_color = "#11acd5"
@@ -35,30 +33,30 @@ def demo(params: HKDemoParams):
params.demo_size = params.demo_size or params.frame_size
# Load backend inference engine
- runner = BackendFactory.create(params.backend, params=params)
+ runner = BackendFactory.get(params.backend)(params)
- classes = sorted(list(set(params.class_map.values())))
+ classes = sorted(set(params.class_map.values()))
class_names = params.class_names or [f"Class {i}" for i in range(params.num_classes)]
feat_shape = (params.frame_size, 1)
class_shape = (params.frame_size, params.num_classes)
- # ds_spec = (
- # tf.TensorSpec(shape=feat_shape, dtype=tf.float32),
- # tf.TensorSpec(shape=class_shape, dtype=tf.int32),
- # )
-
- datasets = load_datasets(datasets=params.datasets)
+ datasets = [DatasetFactory.get(ds.name)(cacheable=False, **ds.params) for ds in params.datasets]
ds = random.choice(datasets)
ds_gen = ds.signal_generator(
- patient_generator=uniform_id_generator(ds.get_test_patient_ids(), repeat=False),
+ patient_generator=nse.utils.uniform_id_generator(ds.get_test_patient_ids(), repeat=False),
frame_size=params.demo_size,
samples_per_patient=5,
target_rate=params.sampling_rate,
)
x = next(ds_gen)
- # Run inference
+
+ augmenter = create_augmentation_pipeline(
+ augmentations=params.augmentations + params.preprocesses,
+ sampling_rate=params.sampling_rate,
+ )
+
runner.open()
logger.debug("Running inference")
y_pred = np.zeros(x.size, dtype=np.int32)
@@ -69,11 +67,11 @@ def demo(params: HKDemoParams):
start, stop = i, i + params.frame_size
xx = x[start:stop]
yy = np.zeros(shape=class_shape, dtype=np.int32)
- xx = augment(x=xx, augmentations=params.augmentations, sample_rate=params.sampling_rate)
- xx = preprocess(xx, sample_rate=params.sampling_rate, preprocesses=params.preprocesses)
xx = xx.reshape(feat_shape)
+ xx = augmenter(xx, training=True)
runner.set_inputs(xx)
runner.perform_inference()
+ x[start:stop] = xx.numpy().squeeze()
yy = runner.get_outputs()
y_pred[start:stop] = np.argmax(yy, axis=-1).flatten()
# END FOR
diff --git a/heartkit/tasks/segmentation/evaluate.py b/heartkit/tasks/segmentation/evaluate.py
index 08d07231..6ae1ea63 100644
--- a/heartkit/tasks/segmentation/evaluate.py
+++ b/heartkit/tasks/segmentation/evaluate.py
@@ -2,25 +2,22 @@
import os
import numpy as np
-import tensorflow as tf
-
import neuralspot_edge as nse
-from ...defines import HKTestParams
-from ...metrics import compute_iou
-from ...utils import set_random_seed, setup_logger
-from ..utils import load_datasets
+
+from ...defines import HKTaskParams
+from ...datasets import DatasetFactory
from .datasets import load_test_dataset
-def evaluate(params: HKTestParams):
+def evaluate(params: HKTaskParams):
"""Evaluate model
Args:
- params (HKTestParams): Evaluation parameters
+ params (HKTaskParams): Evaluation parameters
"""
- logger = setup_logger(__name__, level=params.verbose)
+ logger = nse.utils.setup_logger(__name__, level=params.verbose)
- params.seed = set_random_seed(params.seed)
+ params.seed = nse.utils.set_random_seed(params.seed)
logger.debug(f"Random seed {params.seed}")
os.makedirs(params.job_dir, exist_ok=True)
@@ -32,18 +29,10 @@ def evaluate(params: HKTestParams):
class_names = params.class_names or [f"Class {i}" for i in range(params.num_classes)]
- feat_shape = (params.frame_size, 1)
- class_shape = (params.frame_size, params.num_classes)
-
- ds_spec = (
- tf.TensorSpec(shape=feat_shape, dtype="float32"),
- tf.TensorSpec(shape=class_shape, dtype="int32"),
- )
-
- datasets = load_datasets(datasets=params.datasets)
+ datasets = [DatasetFactory.get(ds.name)(**ds.params) for ds in params.datasets]
- test_ds = load_test_dataset(datasets=datasets, params=params, ds_spec=ds_spec)
- test_x, test_y = next(test_ds.batch(params.test_size).as_numpy_iterator())
+ test_ds = load_test_dataset(datasets=datasets, params=params)
+ test_y = np.concatenate([y for _, y in test_ds.as_numpy_iterator()])
logger.debug("Loading model")
model = nse.models.load_model(params.model_file)
@@ -53,19 +42,18 @@ def evaluate(params: HKTestParams):
logger.debug(f"Model requires {flops/1e6:0.2f} MFLOPS")
logger.debug("Performing inference")
- y_true = np.argmax(test_y, axis=-1)
- y_pred = np.argmax(model.predict(test_x), axis=-1)
+ rst = model.evaluate(test_ds, verbose=params.verbose, return_dict=True)
+ logger.info("[TEST SET] " + ", ".join([f"{k.upper()}={v:.2%}" for k, v in rst.items()]))
- # Summarize results
- logger.info("Testing Results")
- test_acc = np.sum(y_pred == y_true) / y_true.size
- test_iou = compute_iou(y_true, y_pred, average="weighted")
- logger.info(f"[TEST SET] ACC={test_acc:.2%}, IoU={test_iou:.2%}")
+ # Get predictions to compute CM
+ y_true = np.argmax(test_y, axis=-1)
+ y_pred = np.argmax(model.predict(test_ds), axis=-1)
y_true = y_true.flatten()
y_pred = y_pred.flatten()
+
cm_path = params.job_dir / "confusion_matrix_test.png"
- nse.plotting.cm.confusion_matrix_plot(y_true, y_pred, labels=class_names, save_path=cm_path, normalize="true")
- nse.plotting.cm.px_plot_confusion_matrix(
+ nse.plotting.confusion_matrix_plot(y_true, y_pred, labels=class_names, save_path=cm_path, normalize="true")
+ nse.plotting.px_plot_confusion_matrix(
y_true,
y_pred,
labels=class_names,
diff --git a/heartkit/tasks/segmentation/export.py b/heartkit/tasks/segmentation/export.py
index 207a8f19..38a49ce7 100644
--- a/heartkit/tasks/segmentation/export.py
+++ b/heartkit/tasks/segmentation/export.py
@@ -1,69 +1,52 @@
-import logging
import os
import shutil
import keras
import numpy as np
-import tensorflow as tf
-
import neuralspot_edge as nse
-from ...defines import HKExportParams
-from ...metrics import compute_iou
-from ...utils import setup_logger
-from ..utils import load_datasets
+
+from ...defines import HKTaskParams
+from ...datasets import DatasetFactory
from .datasets import load_test_dataset
-def export(params: HKExportParams):
+def export(params: HKTaskParams):
"""Export model
Args:
- params (HKExportParams): Deployment parameters
+ params (HKTaskParams): Deployment parameters
"""
- logger = setup_logger(__name__, level=params.verbose)
-
os.makedirs(params.job_dir, exist_ok=True)
+ logger = nse.utils.setup_logger(__name__, level=params.verbose, file_path=params.job_dir / "export.log")
logger.debug(f"Creating working directory in {params.job_dir}")
- handler = logging.FileHandler(params.job_dir / "export.log", mode="w")
- handler.setLevel(logging.INFO)
- logger.addHandler(handler)
-
tfl_model_path = params.job_dir / "model.tflite"
tflm_model_path = params.job_dir / "model_buffer.h"
- feat_shape = (params.frame_size, 1)
- class_shape = (params.frame_size, params.num_classes)
+ classes = sorted(set(params.class_map.values()))
- ds_spec = (
- tf.TensorSpec(shape=feat_shape, dtype=tf.float32),
- tf.TensorSpec(shape=class_shape, dtype=tf.int32),
- )
+ feat_shape = (params.frame_size, 1)
- datasets = load_datasets(datasets=params.datasets)
+ datasets = [DatasetFactory.get(ds.name)(**ds.params) for ds in params.datasets]
- test_ds = load_test_dataset(datasets=datasets, params=params, ds_spec=ds_spec)
- test_x, test_y = next(test_ds.batch(params.test_size).as_numpy_iterator())
+ test_ds = load_test_dataset(datasets=datasets, params=params)
+ test_x, test_y = [], []
+ for x, y in test_ds.as_numpy_iterator():
+ test_x.append(x)
+ test_y.append(y)
+ test_x = np.concatenate(test_x)
+ test_y = np.concatenate(test_y)
# Load model and set fixed batch size of 1
logger.debug("Loading trained model")
model = nse.models.load_model(params.model_file)
+ # Add softmax layer if required
if not params.use_logits and not isinstance(model.layers[-1], keras.layers.Softmax):
- last_layer_name = model.layers[-1].name
-
- def call_function(layer, *args, **kwargs):
- out = layer(*args, **kwargs)
- if layer.name == last_layer_name:
- out = keras.layers.Softmax()(out)
- return out
-
- # END DEF
- model_clone = keras.models.clone_model(model, call_function=call_function)
- model_clone.set_weights(model.get_weights())
- model = model_clone
+ model = nse.models.append_layers(model, layers=[keras.layers.Softmax()], copy_weights=True)
# END IF
- inputs = keras.Input(shape=ds_spec[0].shape, batch_size=1, name="input", dtype=ds_spec[0].dtype.name)
+
+ inputs = keras.Input(feat_shape, batch_size=1, name="input", dtype="float32")
model(inputs)
flops = nse.metrics.flops.get_flops(model, batch_size=1, fpath=params.job_dir / "model_flops.log")
@@ -97,26 +80,38 @@ def call_function(layer, *args, **kwargs):
tflite = nse.interpreters.tflite.TfLiteKerasInterpreter(tflite_content)
tflite.compile()
- # Verify TFLite results match TF results
- logger.info("Validating model results")
- y_true = np.argmax(test_y, axis=-1)
- y_pred_tf = np.argmax(model.predict(test_x), axis=-1)
- y_pred_tfl = np.argmax(tflite.predict(x=test_x), axis=-1)
-
- tf_acc = np.sum(y_true == y_pred_tf) / y_true.size
- tf_iou = compute_iou(y_true, y_pred_tf, average="weighted")
- logger.info(f"[TF SET] ACC={tf_acc:.2%}, IoU={tf_iou:.2%}")
-
- tfl_acc = np.sum(y_true == y_pred_tfl) / y_true.size
- tfl_iou = compute_iou(y_true, y_pred_tfl, average="weighted")
- logger.info(f"[TFL SET] ACC={tfl_acc:.2%}, IoU={tfl_iou:.2%}")
+ # Verify TFLite results match TF results on example data
+ metrics = [
+ keras.metrics.CategoricalCrossentropy(name="loss", from_logits=params.use_logits),
+ keras.metrics.CategoricalAccuracy(name="acc"),
+ nse.metrics.MultiF1Score(name="f1", average="weighted"),
+ keras.metrics.OneHotIoU(
+ num_classes=params.num_classes,
+ target_class_ids=classes,
+ name="iou",
+ ),
+ ]
+
+ if params.val_metric not in [m.name for m in metrics]:
+ raise ValueError(f"Metric {params.val_metric} not supported")
+
+ logger.debug("Validating model results")
+ y_true = test_y
+ y_pred_tf = model.predict(test_x)
+ y_pred_tfl = tflite.predict(x=test_x)
+
+ tf_rst = nse.metrics.compute_metrics(metrics, y_true, y_pred_tf)
+ tfl_rst = nse.metrics.compute_metrics(metrics, y_true, y_pred_tfl)
+ logger.info("[TF METRICS] " + " ".join([f"{k.upper()}={v:.2%}" for k, v in tf_rst.items()]))
+ logger.info("[TFL METRICS] " + " ".join([f"{k.upper()}={v:.2%}" for k, v in tfl_rst.items()]))
+
+ metric_diff = abs(tf_rst[params.val_metric] - tfl_rst[params.val_metric])
# Check accuracy hit
- tfl_acc_drop = max(0, tf_acc - tfl_acc)
- if params.val_acc_threshold is not None and (1 - tfl_acc_drop) < params.val_acc_threshold:
- logger.warning(f"TFLite accuracy dropped by {tfl_acc_drop:0.2%}")
- elif params.val_acc_threshold:
- logger.debug(f"Validation passed ({tfl_acc_drop:0.2%})")
+ if params.val_metric_threshold is not None and metric_diff > params.val_metric_threshold:
+ logger.warning(f"TFLite accuracy dropped by {metric_diff:0.2%}")
+ elif params.val_metric_threshold:
+ logger.info(f"Validation passed ({metric_diff:0.2%})")
if params.tflm_file and tflm_model_path != params.tflm_file:
logger.debug(f"Copying TFLM header to {params.tflm_file}")
diff --git a/heartkit/tasks/segmentation/metrics.py b/heartkit/tasks/segmentation/metrics.py
deleted file mode 100644
index a80f2660..00000000
--- a/heartkit/tasks/segmentation/metrics.py
+++ /dev/null
@@ -1,45 +0,0 @@
-import matplotlib.pyplot as plt
-import numpy as np
-import numpy.typing as npt
-
-from .defines import HKSegment
-
-
-def plot_segmentations(
- data: npt.NDArray,
- seg_mask: npt.NDArray | None = None,
- fig: plt.Figure | None = None,
- ax: plt.Axes | None = None,
-) -> tuple[plt.Figure, plt.Axes]:
- """Generate line plot of ECG data with lines colored based on segmentation mask
-
- Args:
- data (npt.NDArray): ECG data
- seg_mask (npt.NDArray | None, optional): Segmentation mask. Defaults to None.
- fig (plt.Figure | None, optional): Existing figure handle. Defaults to None.
- ax (plt.Axes | None, optional): Existing axes handle. Defaults to None.
-
- Returns:
- tuple[plt.Figure, plt.Axes]: Figure and axes handle
- """
- color_map = {
- HKSegment.normal: "lightgray",
- HKSegment.pwave: "blue",
- HKSegment.qrs: "orange",
- HKSegment.twave: "green",
- }
- t = np.arange(0, data.shape[0])
- if fig is None or ax is None:
- fig, ax = plt.subplots(figsize=(10, 4), layout="constrained")
- ax.plot(t, data, color="lightgray")
- if seg_mask is not None:
- pred_bnds = np.where(np.abs(np.diff(seg_mask)) > 0)[0]
- pred_bnds = np.concatenate(([0], pred_bnds, [len(seg_mask) - 1]))
- for i in range(pred_bnds.shape[0] - 1):
- c = color_map.get(seg_mask[pred_bnds[i] + 1], "black")
- ax.plot(
- t[pred_bnds[i] : pred_bnds[i + 1]],
- data[pred_bnds[i] : pred_bnds[i + 1]],
- color=c,
- )
- return fig, ax
diff --git a/heartkit/tasks/segmentation/train.py b/heartkit/tasks/segmentation/train.py
index 098c4cb3..167a5a55 100644
--- a/heartkit/tasks/segmentation/train.py
+++ b/heartkit/tasks/segmentation/train.py
@@ -1,46 +1,36 @@
-import logging
import os
import keras
import numpy as np
import sklearn.utils
-import tensorflow as tf
import wandb
from wandb.keras import WandbMetricsLogger, WandbModelCheckpoint
-from sklearn.metrics import f1_score
-
import neuralspot_edge as nse
-from ...defines import HKTrainParams
-from ...metrics import compute_iou
-from ...utils import env_flag, set_random_seed, setup_logger
-from ..utils import load_datasets
+
+from ...defines import HKTaskParams
+from ...datasets import DatasetFactory
from .datasets import load_train_datasets
-from .utils import create_model
+from ...models import ModelFactory
-def train(params: HKTrainParams):
+def train(params: HKTaskParams):
"""Train model
Args:
- params (HKTrainParams): Training parameters
+ params (HKTaskParams): Training parameters
"""
- logger = setup_logger(__name__, level=params.verbose)
-
- params.finetune = bool(getattr(params, "finetune", False))
- params.seed = set_random_seed(params.seed)
- logger.debug(f"Random seed {params.seed}")
-
os.makedirs(params.job_dir, exist_ok=True)
+ logger = nse.utils.setup_logger(__name__, level=params.verbose, file_path=params.job_dir / "train.log")
logger.debug(f"Creating working directory in {params.job_dir}")
- handler = logging.FileHandler(params.job_dir / "train.log", mode="w")
- handler.setLevel(logging.INFO)
- logger.addHandler(handler)
+ params.finetune = bool(getattr(params, "finetune", False))
+ params.seed = nse.utils.set_random_seed(params.seed)
+ logger.debug(f"Random seed {params.seed}")
with open(params.job_dir / "train_config.json", "w", encoding="utf-8") as fp:
fp.write(params.model_dump_json(indent=2))
- if env_flag("WANDB"):
+ if nse.utils.env_flag("WANDB"):
wandb.init(
project=f"hk-segmentation-{params.num_classes}",
entity="ambiq",
@@ -49,96 +39,61 @@ def train(params: HKTrainParams):
wandb.config.update(params.model_dump())
# END IF
- classes = sorted(list(set(params.class_map.values())))
+ classes = sorted(set(params.class_map.values()))
class_names = params.class_names or [f"Class {i}" for i in range(params.num_classes)]
feat_shape = (params.frame_size, 1)
- class_shape = (params.frame_size, params.num_classes)
-
- ds_spec = (
- tf.TensorSpec(shape=feat_shape, dtype=tf.float32),
- tf.TensorSpec(shape=class_shape, dtype=tf.int32),
- )
- datasets = load_datasets(datasets=params.datasets)
+ datasets = [DatasetFactory.get(ds.name)(**ds.params) for ds in params.datasets]
train_ds, val_ds = load_train_datasets(
datasets=datasets,
params=params,
- ds_spec=ds_spec,
)
- test_labels = [label.numpy() for _, label in val_ds]
- # Where test_labels is all zeros, we assume it is a dummy label and should be ignored
- y_mask = np.any(test_labels, axis=-1).flatten()
- y_true = np.argmax(np.concatenate(test_labels).squeeze(), axis=-1).flatten()
+ y_true = np.concatenate([xy[1] for xy in val_ds.as_numpy_iterator()])
+ y_true = np.argmax(y_true, axis=-1).flatten()
class_weights = 0.25
if params.class_weights == "balanced":
class_weights = sklearn.utils.compute_class_weight("balanced", classes=np.array(classes), y=y_true)
class_weights = (class_weights + class_weights.mean()) / 2 # Smooth out
+ class_weights = class_weights.tolist()
# END IF
logger.debug(f"Class weights: {class_weights}")
- inputs = keras.Input(
- shape=ds_spec[0].shape,
- batch_size=None,
- name="input",
- dtype=ds_spec[0].dtype.name,
- )
+ inputs = keras.Input(shape=feat_shape, name="input", dtype="float32")
+
if params.resume and params.model_file:
logger.debug(f"Loading model from file {params.model_file}")
model = nse.models.load_model(params.model_file)
params.model_file = None
else:
logger.debug("Creating model from scratch")
- model = create_model(
- inputs,
+ model = ModelFactory.get(params.architecture.name)(
+ x=inputs,
+ params=params.architecture.params,
num_classes=params.num_classes,
- architecture=params.architecture,
)
# END IF
- # If fine-tune, freeze model encoder weights
- if params.finetune:
- for layer in model.layers:
- if layer.name.startswith("ENC"):
- logger.debug(f"Freezing {layer.name}")
- layer.trainable = False
- # END IF
- # END FOR
- # END IF
-
flops = nse.metrics.flops.get_flops(model, batch_size=1, fpath=params.job_dir / "model_flops.log")
- if params.lr_cycles > 1:
- scheduler = keras.optimizers.schedules.CosineDecayRestarts(
- initial_learning_rate=params.lr_rate,
- first_decay_steps=int(0.1 * params.steps_per_epoch * params.epochs),
- t_mul=1.65 / (0.1 * params.lr_cycles * (params.lr_cycles - 1)),
- m_mul=0.4,
- )
- else:
- scheduler = keras.optimizers.schedules.CosineDecay(
- initial_learning_rate=params.lr_rate,
- decay_steps=params.steps_per_epoch * params.epochs,
- )
- # END IF
+ t_mul = 1
+ first_steps = (params.steps_per_epoch * params.epochs) / (np.power(params.lr_cycles, t_mul) - t_mul + 1)
+ scheduler = keras.optimizers.schedules.CosineDecayRestarts(
+ initial_learning_rate=params.lr_rate,
+ first_decay_steps=np.ceil(first_steps),
+ t_mul=t_mul,
+ m_mul=0.5,
+ )
optimizer = keras.optimizers.Adam(scheduler)
loss = keras.losses.CategoricalFocalCrossentropy(
from_logits=True,
alpha=class_weights,
)
- metrics = [
- keras.metrics.CategoricalAccuracy(name="acc"),
- # tfa.MultiF1Score(name="f1", average="weighted"),
- keras.metrics.OneHotIoU(
- num_classes=params.num_classes,
- target_class_ids=classes,
- name="iou",
- ),
- ]
+ metrics = [keras.metrics.CategoricalAccuracy(name="acc"), nse.metrics.MultiF1Score(name="f1", average="weighted")]
if params.resume and params.weights_file:
logger.debug(f"Hydrating model weights from file {params.weights_file}")
@@ -153,7 +108,7 @@ def train(params: HKTrainParams):
logger.debug(f"Model requires {flops/1e6:0.2f} MFLOPS")
ModelCheckpoint = keras.callbacks.ModelCheckpoint
- if env_flag("WANDB"):
+ if nse.utils.env_flag("WANDB"):
ModelCheckpoint = WandbModelCheckpoint
model_callbacks = [
keras.callbacks.EarlyStopping(
@@ -173,14 +128,14 @@ def train(params: HKTrainParams):
),
keras.callbacks.CSVLogger(params.job_dir / "history.csv"),
]
- if env_flag("TENSORBOARD"):
+ if nse.utils.env_flag("TENSORBOARD"):
model_callbacks.append(
keras.callbacks.TensorBoard(
log_dir=params.job_dir,
write_steps_per_second=True,
)
)
- if env_flag("WANDB"):
+ if nse.utils.env_flag("WANDB"):
model_callbacks.append(WandbMetricsLogger())
try:
@@ -198,23 +153,18 @@ def train(params: HKTrainParams):
logger.debug(f"Model saved to {params.model_file}")
# Get full validation results
- keras.models.load_model(params.model_file)
logger.debug("Performing full validation")
- y_pred = np.argmax(model.predict(val_ds), axis=-1).flatten()
-
- # Keep only valid labels
- y_true = y_true[y_mask]
- y_pred = y_pred[y_mask]
+ y_pred = model.predict(val_ds)
+ y_pred = np.argmax(y_pred, axis=-1).flatten()
cm_path = params.job_dir / "confusion_matrix.png"
- nse.plotting.cm.confusion_matrix_plot(y_true, y_pred, labels=class_names, save_path=cm_path, normalize="true")
- if env_flag("WANDB"):
+ nse.plotting.confusion_matrix_plot(y_true, y_pred, labels=class_names, save_path=cm_path, normalize="true")
+ if nse.utils.env_flag("WANDB"):
conf_mat = wandb.plot.confusion_matrix(preds=y_pred, y_true=y_true, class_names=class_names)
wandb.log({"conf_mat": conf_mat})
# END IF
# Summarize results
- test_acc = np.sum(y_pred == y_true) / y_true.size
- test_f1 = f1_score(y_true=y_true, y_pred=y_pred, average="weighted")
- test_iou = compute_iou(y_true, y_pred, average="weighted")
- logger.info(f"[TEST SET] ACC={test_acc:.2%}, F1={test_f1:.2%} IoU={test_iou:0.2%}")
+ rst = model.evaluate(val_ds, verbose=params.verbose, return_dict=True)
+ msg = "[VAL SET] " + ", ".join([f"{k.upper()}={v:.2%}" for k, v in rst.items()])
+ logger.info(msg)
diff --git a/heartkit/tasks/segmentation/utils.py b/heartkit/tasks/segmentation/utils.py
deleted file mode 100644
index b0f2ee40..00000000
--- a/heartkit/tasks/segmentation/utils.py
+++ /dev/null
@@ -1,60 +0,0 @@
-import keras
-from neuralspot_edge.models.unet import UNet, UNetBlockParams, UNetParams
-from rich.console import Console
-
-from ...defines import ModelArchitecture
-from ...models import ModelFactory
-
-console = Console()
-
-
-def create_model(inputs: keras.KerasTensor, num_classes: int, architecture: ModelArchitecture | None) -> keras.Model:
- """Generate model or use default
-
- Args:
- inputs (keras.KerasTensor): Model inputs
- num_classes (int): Number of classes
- architecture (ModelArchitecture|None): Model
-
- Returns:
- keras.Model: Model
- """
- if architecture:
- return ModelFactory.get(architecture.name)(
- x=inputs,
- params=architecture.params,
- num_classes=num_classes,
- )
-
- return default_model(inputs=inputs, num_classes=num_classes)
-
-
-def default_model(
- inputs: keras.KerasTensor,
- num_classes: int,
-) -> keras.Model:
- """Reference model
-
- Args:
- inputs (keras.KerasTensor): Model inputs
- num_classes (int): Number of classes
-
- Returns:
- keras.Model: Model
- """
- blocks = [
- UNetBlockParams(filters=8, depth=2, ddepth=1, kernel=(1, 3), strides=(1, 2), skip=True),
- UNetBlockParams(filters=16, depth=2, ddepth=1, kernel=(1, 3), strides=(1, 2), skip=True),
- UNetBlockParams(filters=24, depth=2, ddepth=1, kernel=(1, 3), strides=(1, 2), skip=True),
- UNetBlockParams(filters=32, depth=2, ddepth=1, kernel=(1, 3), strides=(1, 2), skip=True),
- UNetBlockParams(filters=40, depth=2, ddepth=1, kernel=(1, 3), strides=(1, 2), skip=True),
- ]
- return UNet(
- inputs,
- params=UNetParams(
- blocks=blocks,
- output_kernel_size=(1, 3),
- include_top=True,
- ),
- num_classes=num_classes,
- )
diff --git a/heartkit/tasks/task.py b/heartkit/tasks/task.py
index fe87e634..c45feae7 100644
--- a/heartkit/tasks/task.py
+++ b/heartkit/tasks/task.py
@@ -1,6 +1,6 @@
import abc
-from ..defines import HKDemoParams, HKExportParams, HKTestParams, HKTrainParams
+from ..defines import HKTaskParams
class HKTask(abc.ABC):
@@ -17,41 +17,41 @@ def description() -> str:
return ""
@staticmethod
- def train(params: HKTrainParams) -> None:
+ def train(params: HKTaskParams) -> None:
"""Train a model
Args:
- params (HKTrainParams): train parameters
+ params (HKTaskParams): train parameters
"""
raise NotImplementedError
@staticmethod
- def evaluate(params: HKTestParams) -> None:
+ def evaluate(params: HKTaskParams) -> None:
"""Evaluate a model
Args:
- params (HKTestParams): test parameters
+ params (HKTaskParams): test parameters
"""
raise NotImplementedError
@staticmethod
- def export(params: HKExportParams) -> None:
+ def export(params: HKTaskParams) -> None:
"""Export a model
Args:
- params (HKExportParams): export parameters
+ params (HKTaskParams): export parameters
"""
raise NotImplementedError
@staticmethod
- def demo(params: HKDemoParams) -> None:
+ def demo(params: HKTaskParams) -> None:
"""Run a demo
Args:
- params (HKDemoParams): demo parameters
+ params (HKTaskParams): demo parameters
"""
raise NotImplementedError
diff --git a/heartkit/tasks/translate/__init__.py b/heartkit/tasks/translate/__init__.py
index 4543e708..7ac6f120 100644
--- a/heartkit/tasks/translate/__init__.py
+++ b/heartkit/tasks/translate/__init__.py
@@ -1,4 +1,4 @@
-from ...defines import HKDemoParams, HKExportParams, HKTestParams, HKTrainParams
+from ...defines import HKTaskParams
from ..task import HKTask
from .defines import HKTranslate
from .demo import demo
@@ -11,17 +11,17 @@ class TranslateTask(HKTask):
"""HeartKit Translate Task"""
@staticmethod
- def train(params: HKTrainParams):
+ def train(params: HKTaskParams):
train(params)
@staticmethod
- def evaluate(params: HKTestParams):
+ def evaluate(params: HKTaskParams):
evaluate(params)
@staticmethod
- def export(params: HKExportParams):
+ def export(params: HKTaskParams):
export(params)
@staticmethod
- def demo(params: HKDemoParams):
+ def demo(params: HKTaskParams):
demo(params)
diff --git a/heartkit/tasks/translate/dataloaders/__init__.py b/heartkit/tasks/translate/dataloaders/__init__.py
index 968977f3..efc56a4e 100644
--- a/heartkit/tasks/translate/dataloaders/__init__.py
+++ b/heartkit/tasks/translate/dataloaders/__init__.py
@@ -1 +1,8 @@
-from .bidmc import bidmc_data_generator
+import neuralspot_edge as nse
+
+from ....datasets import HKDataloader
+
+from .bidmc import BidmcDataloader
+
+TranslateTaskFactory = nse.utils.create_factory(factory="HKTranslateTaskFactory", type=HKDataloader)
+TranslateTaskFactory.register("bidmc", BidmcDataloader)
diff --git a/heartkit/tasks/translate/dataloaders/bidmc.py b/heartkit/tasks/translate/dataloaders/bidmc.py
index 0d6a26ac..bebf27fb 100644
--- a/heartkit/tasks/translate/dataloaders/bidmc.py
+++ b/heartkit/tasks/translate/dataloaders/bidmc.py
@@ -3,56 +3,68 @@
import numpy as np
import numpy.typing as npt
import physiokit as pk
+import neuralspot_edge as nse
-from ....datasets import BidmcDataset, PatientGenerator
+from ....datasets import BidmcDataset, HKDataloader
+from ..defines import HKTranslate
-def bidmc_data_generator(
- patient_generator: PatientGenerator,
- ds: BidmcDataset,
- frame_size: int,
- samples_per_patient: int | list[int] = 1,
- target_rate: int | None = None,
-) -> Generator[tuple[npt.NDArray, npt.NDArray], None, None]:
- """Generate frames using patient generator.
+BidmcTranslateMap = {0: HKTranslate.ecg, 1: HKTranslate.ppg}
- Args:
- patient_generator (PatientGenerator): Patient Generator
- ds: BidmcDataset
- frame_size (int): Frame size
- samples_per_patient (int | list[int], optional): # samples per patient. Defaults to 1.
- target_rate (int|None, optional): Target rate. Defaults to None.
- Returns:
- Generator[tuple[npt.NDArray, npt.NDArray], None, None]: Sample generator
+class BidmcDataloader(HKDataloader):
+ """Dataloader for the BIDMC dataset"""
- """
- if isinstance(samples_per_patient, Iterable):
- samples_per_patient = samples_per_patient[0]
+ def __init__(self, ds: BidmcDataset, **kwargs):
+ super().__init__(ds=ds, **kwargs)
+ if self.label_map is None:
+ self.label_map = {HKTranslate.ppg: HKTranslate.ecg}
+ if len(self.label_map) != 1:
+ raise ValueError("Only one source and target signal is supported")
+ self.label_map = {k: self.label_map[v] for (k, v) in BidmcTranslateMap.items() if k in self.label_map}
- for pt in patient_generator:
- with ds.patient_data(pt) as h5:
- ecg = h5["data"][0, :]
- ppg = h5["data"][1, :]
- # END WITH
+ def patient_data_generator(
+ self,
+ patient_id: int,
+ samples_per_patient: int,
+ ):
+ # Use class_map to determine source and target signals
+ src, tgt = list(self.label_map.keys())[0], list(self.label_map.values())[0]
- # Use translation map to determine source and target signals
- x = ppg
- y = ecg
+ with self.ds.patient_data(patient_id) as h5:
+ x = h5["data"][src, :]
+ y = h5["data"][tgt, :]
+ # END WITH
# Resample signals if necessary
- if ds.sampling_rate != target_rate:
- x = pk.signal.resample_signal(x, ds.sampling_rate, target_rate, axis=0)
- y = pk.signal.resample_signal(y, ds.sampling_rate, target_rate, axis=0)
+ if self.ds.sampling_rate != self.sampling_rate:
+ x = pk.signal.resample_signal(x, self.ds.sampling_rate, self.sampling_rate, axis=0)
+ y = pk.signal.resample_signal(y, self.ds.sampling_rate, self.sampling_rate, axis=0)
# END IF
# Generate samples
for _ in range(samples_per_patient):
- start = np.random.randint(0, x.size - frame_size)
- xx = x[start : start + frame_size]
+ start = np.random.randint(0, x.size - self.frame_size)
+ xx = x[start : start + self.frame_size]
xx = np.nan_to_num(xx).astype(np.float32)
- yy = y[start : start + frame_size]
+ yy = y[start : start + self.frame_size]
yy = np.nan_to_num(yy).astype(np.float32)
+ xx = xx.reshape(-1, 1)
+ yy = yy.reshape(-1, 1)
yield xx, yy
# END FOR
- # END FOR
+
+ def data_generator(
+ self,
+ patient_ids: list[int],
+ samples_per_patient: int | list[int],
+ shuffle: bool = False,
+ ) -> Generator[tuple[npt.NDArray, npt.NDArray], None, None]:
+ if isinstance(samples_per_patient, Iterable):
+ samples_per_patient = samples_per_patient[0]
+
+ for pt_id in nse.utils.uniform_id_generator(patient_ids, shuffle=shuffle):
+ for x, y in self.patient_data_generator(pt_id, samples_per_patient):
+ yield x, y
+ # END FOR
+ # END FOR
diff --git a/heartkit/tasks/translate/datasets.py b/heartkit/tasks/translate/datasets.py
index a4852884..81191e1f 100644
--- a/heartkit/tasks/translate/datasets.py
+++ b/heartkit/tasks/translate/datasets.py
@@ -1,313 +1,157 @@
-import functools
-import logging
-from pathlib import Path
-
import numpy as np
-import numpy.typing as npt
import tensorflow as tf
+import neuralspot_edge as nse
from ...datasets import (
HKDataset,
- augment_pipeline,
- preprocess_pipeline,
- uniform_id_generator,
-)
-from ...datasets.dataloader import test_dataloader, train_val_dataloader
-from ...defines import (
- AugmentationParams,
- HKExportParams,
- HKTestParams,
- HKTrainParams,
- PreprocessParams,
+ create_augmentation_pipeline,
)
-from ...utils import resolve_template_path
-from .dataloaders import bidmc_data_generator
-
-logger = logging.getLogger(__name__)
-
-
-def preprocess(x: npt.NDArray, preprocesses: list[PreprocessParams], sample_rate: float) -> npt.NDArray:
- """Preprocess data pipeline
-
- Args:
- x (npt.NDArray): Input data
- preprocesses (list[PreprocessParams]): Preprocess parameters
- sample_rate (float): Sample rate
-
- Returns:
- npt.NDArray: Preprocessed data
- """
- return preprocess_pipeline(x, preprocesses=preprocesses, sample_rate=sample_rate)
-
-
-def augment(x: npt.NDArray, augmentations: list[AugmentationParams], sample_rate: float) -> npt.NDArray:
- """Augment data pipeline
-
- Args:
- x (npt.NDArray): Input data
- augmentations (list[AugmentationParams]): Augmentation parameters
- sample_rate (float): Sample rate
-
- Returns:
- npt.NDArray: Augmented data
- """
-
- return augment_pipeline(x=x, augmentations=augmentations, sample_rate=sample_rate)
-
-
-def prepare(
- x_y: tuple[npt.NDArray, npt.NDArray],
- sample_rate: float,
- preprocesses: list[PreprocessParams],
- augmentations: list[AugmentationParams],
- spec: tuple[tf.TensorSpec, tf.TensorSpec],
- num_classes: int,
-) -> tuple[npt.NDArray, npt.NDArray]:
- """Prepare dataset
-
- Args:
- x_y (tuple[npt.NDArray, npt.NDArray]): Input data
- sample_rate (float): Sample rate
- preprocesses (list[PreprocessParams]|None): Preprocess parameters
- augmentations (list[AugmentationParams]|None): Augmentation parameters
- spec (tuple[tf.TensorSpec, tf.TensorSpec]): TensorSpec
- num_classes (int): Number of classes
-
- Returns:
- tuple[npt.NDArray, npt.NDArray]: Prepared data
- """
- x, y = x_y[0].copy(), x_y[1].copy()
-
- if augmentations:
- x = augment(x, augmentations, sample_rate)
- y = augment(y, augmentations, sample_rate)
- # END IF
-
- if preprocesses:
- x = preprocess(x, preprocesses, sample_rate)
- y = preprocess(y, preprocesses, sample_rate)
- # END IF
+from ...datasets.dataloader import HKDataloader
+from ...defines import HKTaskParams, NamedParams
- x = x.reshape(spec[0].shape)
- # y = y.reshape(spec[0].shape)
- y = y.reshape(spec[1].shape)
+from .dataloaders import TranslateTaskFactory
- return x, y
+logger = nse.utils.setup_logger(__name__)
-def get_ds_label_map(ds: HKDataset, label_map: dict[int, int] | None = None) -> dict[int, int]:
- """Get label map for dataset
-
- Args:
- ds (HKDataset): Dataset
- label_map (dict[int, int]|None): Label map
-
- Returns:
- dict[int, int]: Label map
- """
- return label_map
-
-
-def get_data_generator(ds: HKDataset, frame_size: int, samples_per_patient: int, target_rate: int):
- """Get task data generator for dataset
-
- Args:
- ds (HKDataset): Dataset
- frame_size (int): Frame size
- samples_per_patient (int): Samples per patient
- target_rate (int): Target rate
-
- Returns:
- callable: Data generator
- """
- match ds.name:
- case "bidmc":
- data_generator = bidmc_data_generator
- case _:
- raise ValueError(f"Dataset {ds.name} not supported")
- # END MATCH
- return functools.partial(
- data_generator,
- ds=ds,
- frame_size=frame_size,
- samples_per_patient=samples_per_patient,
- target_rate=target_rate,
+def create_data_pipeline(
+ ds: tf.data.Dataset,
+ sampling_rate: int,
+ batch_size: int,
+ buffer_size: int | None = None,
+ augmentations: list[NamedParams] | None = None,
+):
+ if buffer_size:
+ ds = ds.shuffle(
+ buffer_size=buffer_size,
+ reshuffle_each_iteration=True,
+ )
+ if batch_size:
+ ds = ds.batch(
+ batch_size=batch_size,
+ drop_remainder=True,
+ num_parallel_calls=tf.data.AUTOTUNE,
+ )
+ augmenter = create_augmentation_pipeline(augmentations, sampling_rate=sampling_rate)
+ ds = (
+ ds.map(
+ lambda data, labels: {
+ "data": tf.cast(data, "float32"),
+ "labels": tf.cast(labels, "float32"),
+ },
+ num_parallel_calls=tf.data.AUTOTUNE,
+ )
+ .map(
+ augmenter,
+ num_parallel_calls=tf.data.AUTOTUNE,
+ )
+ .map(
+ lambda data: (data["data"], data["labels"]),
+ num_parallel_calls=tf.data.AUTOTUNE,
+ )
)
-
-def resolve_ds_cache_path(fpath: Path | None, ds: HKDataset, task: str, frame_size: int, sample_rate: int):
- """Resolve dataset cache path
-
- Args:
- fpath (Path|None): File path
- ds (HKDataset): Dataset
- task (str): Task
- frame_size (int): Frame size
- sample_rate (int): Sampling rate
-
- Returns:
- Path|None: Resolved path
- """
- if not fpath:
- return None
- return resolve_template_path(
- fpath=fpath,
- dataset=ds.name,
- task=task,
- frame_size=frame_size,
- sampling_rate=sample_rate,
- )
+ return ds.prefetch(tf.data.AUTOTUNE)
def load_train_datasets(
datasets: list[HKDataset],
- params: HKTrainParams,
- ds_spec: tuple[tf.TensorSpec, tf.TensorSpec],
+ params: HKTaskParams,
) -> tuple[tf.data.Dataset, tf.data.Dataset]:
- """Load training and validation datasets
-
- Args:
- datasets (list[HKDataset]): Datasets
- params (HKTrainParams): Training parameters
- ds_spec (tuple[tf.TensorSpec, tf.TensorSpec]): TensorSpec
-
- Returns:
- tuple[tf.data.Dataset, tf.data.Dataset]: Train and validation datasets
- """
- id_generator = functools.partial(uniform_id_generator, repeat=True)
- train_prepare = functools.partial(
- prepare,
- sample_rate=params.sampling_rate,
- preprocesses=params.preprocesses,
- augmentations=params.augmentations,
- spec=ds_spec,
- num_classes=params.num_classes,
- )
-
train_datasets = []
val_datasets = []
for ds in datasets:
- val_file = resolve_ds_cache_path(
- params.val_file,
- ds=ds,
- task="denoise",
- frame_size=params.frame_size,
- sample_rate=params.sampling_rate,
- )
- data_generator = get_data_generator(
+ dataloader: HKDataloader = TranslateTaskFactory.get(ds.name)(
ds=ds,
frame_size=params.frame_size,
- samples_per_patient=params.samples_per_patient,
- target_rate=params.sampling_rate,
+ sampling_rate=params.sampling_rate,
+ label_map=params.class_map,
)
-
- train_ds, val_ds = train_val_dataloader(
- ds=ds,
- spec=ds_spec,
- data_generator=data_generator,
- id_generator=id_generator,
+ train_patients, val_patients = dataloader.split_train_val_patients(
train_patients=params.train_patients,
val_patients=params.val_patients,
- val_pt_samples=params.val_samples_per_patient,
- val_file=val_file,
- val_size=params.val_size,
- label_map=None,
- label_type=None,
- preprocess=train_prepare,
- num_workers=params.data_parallelism,
+ )
+
+ train_ds = dataloader.create_dataloader(
+ patient_ids=train_patients, samples_per_patient=params.samples_per_patient, shuffle=True
+ )
+
+ val_ds = dataloader.create_dataloader(
+ patient_ids=val_patients, samples_per_patient=params.val_samples_per_patient, shuffle=False
)
train_datasets.append(train_ds)
val_datasets.append(val_ds)
# END FOR
- ds_weights = np.array([d.weight for d in params.datasets])
- ds_weights = ds_weights / ds_weights.sum()
+ ds_weights = None
+ if params.dataset_weights:
+ ds_weights = np.array(params.dataset_weights)
+ ds_weights = ds_weights / ds_weights.sum()
train_ds = tf.data.Dataset.sample_from_datasets(train_datasets, weights=ds_weights)
val_ds = tf.data.Dataset.sample_from_datasets(val_datasets, weights=ds_weights)
# Shuffle and batch datasets for training
- train_ds = (
- train_ds.shuffle(
- buffer_size=params.buffer_size,
- reshuffle_each_iteration=True,
- )
- .batch(
- batch_size=params.batch_size,
- drop_remainder=False,
- num_parallel_calls=tf.data.AUTOTUNE,
- )
- .prefetch(buffer_size=tf.data.AUTOTUNE)
+ train_ds = create_data_pipeline(
+ ds=train_ds,
+ sampling_rate=params.sampling_rate,
+ batch_size=params.batch_size,
+ buffer_size=params.buffer_size,
+ augmentations=params.augmentations + params.preprocesses,
)
- val_ds = val_ds.batch(
+
+ val_ds = create_data_pipeline(
+ ds=val_ds,
+ sampling_rate=params.sampling_rate,
batch_size=params.batch_size,
- drop_remainder=True,
- num_parallel_calls=tf.data.AUTOTUNE,
+ augmentations=params.preprocesses,
)
+
+ # If given fixed val size or steps, then capture and cache
+ val_steps_per_epoch = params.val_size // params.batch_size if params.val_size else params.val_steps_per_epoch
+ if val_steps_per_epoch:
+ logger.info(f"Validation steps per epoch: {val_steps_per_epoch}")
+ val_ds = val_ds.take(val_steps_per_epoch).cache()
+
return train_ds, val_ds
def load_test_dataset(
datasets: list[HKDataset],
- params: HKTestParams | HKExportParams,
- ds_spec: tuple[tf.TensorSpec, tf.TensorSpec],
+ params: HKTaskParams,
) -> tf.data.Dataset:
- """Load test dataset
-
- Args:
- datasets (list[HKDataset]): Datasets
- params (HKTestParams|HKExportParams): Test parameters
- ds_spec (tuple[tf.TensorSpec, tf.TensorSpec]): TensorSpec
-
- Returns:
- tf.data.Dataset: Test dataset
- """
-
- id_generator = functools.partial(uniform_id_generator, repeat=True)
- test_prepare = functools.partial(
- prepare,
- sample_rate=params.sampling_rate,
- preprocesses=params.preprocesses,
- augmentations=params.augmentations,
- spec=ds_spec,
- num_classes=params.num_classes,
- )
-
test_datasets = []
for ds in datasets:
- test_file = resolve_ds_cache_path(
- fpath=params.test_file,
+ dataloader: HKDataloader = TranslateTaskFactory.get(ds.name)(
ds=ds,
- task="translate",
frame_size=params.frame_size,
- sample_rate=params.sampling_rate,
+ sampling_rate=params.sampling_rate,
+ label_map=params.class_map,
)
- data_generator = get_data_generator(
- ds=ds,
- frame_size=params.frame_size,
+ test_patients = dataloader.test_patient_ids(params.test_patients)
+ test_ds = dataloader.create_dataloader(
+ patient_ids=test_patients,
samples_per_patient=params.test_samples_per_patient,
- target_rate=params.sampling_rate,
- )
-
- test_ds = test_dataloader(
- ds=ds,
- spec=ds_spec,
- data_generator=data_generator,
- id_generator=id_generator,
- test_patients=params.test_patients,
- test_file=test_file,
- label_map=None,
- label_type=None,
- preprocess=test_prepare,
- num_workers=params.data_parallelism,
+ shuffle=False,
)
test_datasets.append(test_ds)
# END FOR
- ds_weights = np.array([d.weight for d in params.datasets])
- ds_weights = ds_weights / ds_weights.sum()
+ ds_weights = None
+ if params.dataset_weights:
+ ds_weights = np.array(params.dataset_weights)
+ ds_weights = ds_weights / ds_weights.sum()
test_ds = tf.data.Dataset.sample_from_datasets(test_datasets, weights=ds_weights)
+ test_ds = create_data_pipeline(
+ ds=test_ds,
+ sampling_rate=params.sampling_rate,
+ batch_size=params.batch_size,
+ augmentations=params.preprocesses,
+ )
+
+ if params.test_size:
+ batch_size = getattr(params, "batch_size", 1)
+ test_ds = test_ds.take(params.test_size // batch_size).cache()
- # END WITH
return test_ds
diff --git a/heartkit/tasks/translate/demo.py b/heartkit/tasks/translate/demo.py
index 4ff6c190..d62ecfbe 100644
--- a/heartkit/tasks/translate/demo.py
+++ b/heartkit/tasks/translate/demo.py
@@ -2,25 +2,22 @@
import numpy as np
import plotly.graph_objects as go
-import tensorflow as tf
from plotly.subplots import make_subplots
from tqdm import tqdm
+import neuralspot_edge as nse
-from ...datasets.utils import uniform_id_generator
-from ...defines import HKDemoParams
+from ...defines import HKTaskParams
from ...rpc import BackendFactory
-from ...utils import setup_logger
-from ..utils import load_datasets
-from .datasets import get_data_generator, prepare
+from ...datasets import DatasetFactory
-logger = setup_logger(__name__)
+logger = nse.utils.setup_logger(__name__)
-def demo(params: HKDemoParams):
+def demo(params: HKTaskParams):
"""Run task demo.
Args:
- params (HKDemoParams): Demo parameters
+ params (HKTaskParams): Demo parameters
"""
bg_color = "rgba(38,42,50,1.0)"
primary_color = "#11acd5"
@@ -32,36 +29,33 @@ def demo(params: HKDemoParams):
params.demo_size = params.demo_size or 10 * params.sampling_rate
# Load backend inference engine
- runner = BackendFactory.create(params.backend, params=params)
+ runner = BackendFactory.get(params.backend)(params=params)
- feat_shape = (params.demo_size, 1)
- class_shape = (params.demo_size, 1)
-
- ds_spec = (
- tf.TensorSpec(shape=feat_shape, dtype=tf.float32),
- tf.TensorSpec(shape=class_shape, dtype=tf.float32),
- )
+ # feat_shape = (params.demo_size, 1)
+ # class_shape = (params.demo_size, 1)
# Load data
- dsets = load_datasets(datasets=params.datasets)
- ds = random.choice(dsets)
-
- ds_gen = get_data_generator(
- ds, frame_size=params.demo_size, samples_per_patient=5, target_rate=params.sampling_rate
- )
-
- ds_gen = ds_gen(patient_generator=uniform_id_generator(ds.get_test_patient_ids(), repeat=False))
-
- x, y = next(ds_gen)
-
- x, y = prepare(
- (x, y),
- sample_rate=params.sampling_rate,
- preprocesses=params.preprocesses,
- augmentations=params.augmentations,
- spec=ds_spec,
- num_classes=params.num_classes,
- )
+ datasets = [DatasetFactory.get(ds.name)(**ds.params) for ds in params.datasets]
+ ds = random.choice(datasets)
+ print(ds)
+
+ # ds_gen = get_data_generator(
+ # ds, frame_size=params.demo_size, samples_per_patient=5, target_rate=params.sampling_rate
+ # )
+
+ # ds_gen = ds_gen(patient_generator=nse.utils.uniform_id_generator(ds.get_test_patient_ids(), repeat=False))
+
+ # x, y = next(ds_gen)
+
+ # x, y = prepare(
+ # (x, y),
+ # sample_rate=params.sampling_rate,
+ # preprocesses=params.preprocesses,
+ # augmentations=params.augmentations,
+ # spec=ds_spec,
+ # num_classes=params.num_classes,
+ # )
+ x, y = None, None
x = x.flatten()
y = y.flatten()
diff --git a/heartkit/tasks/translate/evaluate.py b/heartkit/tasks/translate/evaluate.py
index 3fc57227..3ad470cd 100644
--- a/heartkit/tasks/translate/evaluate.py
+++ b/heartkit/tasks/translate/evaluate.py
@@ -1,27 +1,22 @@
import logging
import os
-import keras
-import numpy as np
-import tensorflow as tf
-
import neuralspot_edge as nse
-from ...defines import HKTestParams
-from ...utils import set_random_seed, setup_logger
-from ..utils import load_datasets
-from .datasets import load_test_dataset
+from ...defines import HKTaskParams
-logger = setup_logger(__name__)
+from ...datasets import DatasetFactory
+from .datasets import load_test_dataset
-def evaluate(params: HKTestParams):
+def evaluate(params: HKTaskParams):
"""Evaluate model
Args:
- params (HKTestParams): Evaluation parameters
+ params (HKTaskParams): Evaluation parameters
"""
+ logger = nse.utils.setup_logger(__name__, level=params.verbose)
- params.seed = set_random_seed(params.seed)
+ params.seed = nse.utils.set_random_seed(params.seed)
logger.debug(f"Random seed {params.seed}")
os.makedirs(params.job_dir, exist_ok=True)
@@ -31,17 +26,9 @@ def evaluate(params: HKTestParams):
handler.setLevel(logging.INFO)
logger.addHandler(handler)
- feat_shape = (params.frame_size, 1)
- class_shape = (params.frame_size, 1)
-
- ds_spec = (
- tf.TensorSpec(shape=feat_shape, dtype="float32"),
- tf.TensorSpec(shape=class_shape, dtype="float32"),
- )
-
- datasets = load_datasets(datasets=params.datasets)
+ datasets = [DatasetFactory.get(ds.name)(**ds.params) for ds in params.datasets]
- test_ds = load_test_dataset(datasets=datasets, params=params, ds_spec=ds_spec)
+ test_ds = load_test_dataset(datasets=datasets, params=params)
test_x, test_y = next(test_ds.batch(params.test_size).as_numpy_iterator())
logger.debug("Loading model")
@@ -52,20 +39,10 @@ def evaluate(params: HKTestParams):
logger.debug(f"Model requires {flops/1e6:0.2f} MFLOPS")
logger.debug("Performing inference")
- y_true = test_y.squeeze()
- y_prob = model.predict(test_x)
- y_pred = y_prob.squeeze()
+ # y_true = test_y.squeeze()
+ # y_prob = model.predict(test_x)
+ # y_pred = y_prob.squeeze()
# Summarize results
- cossim = keras.metrics.CosineSimilarity()
- cossim.update_state(y_true, y_pred) # pylint: disable=E1102
- test_cossim = cossim.result().numpy() # pylint: disable=E1102
- logger.debug("Testing Results")
- mae = keras.metrics.MeanAbsoluteError()
- mae.update_state(y_true, y_pred) # pylint: disable=E1102
- test_mae = mae.result().numpy() # pylint: disable=E1102
- mse = keras.metrics.MeanSquaredError()
- mse.update_state(y_true, y_pred) # pylint: disable=E1102
- test_mse = mse.result().numpy() # pylint: disable=E1102
- np.sqrt(np.mean(np.square(y_true - y_pred)))
- logger.info(f"[TEST SET] MAE={test_mae:.2%}, MSE={test_mse:.2%}, COSSIM={test_cossim:.2%}")
+ metrics = model.evaluate(test_x, test_y, verbose=params.verbose, return_dict=True)
+ logger.info("[TEST SET] " + ", ".join([f"{k.upper()}={v:.2%}" for k, v in metrics.items()]))
diff --git a/heartkit/tasks/translate/export.py b/heartkit/tasks/translate/export.py
index 732c7839..6db2b7b8 100644
--- a/heartkit/tasks/translate/export.py
+++ b/heartkit/tasks/translate/export.py
@@ -1,56 +1,39 @@
-import logging
import os
import shutil
import keras
-import numpy as np
-import tensorflow as tf
-
import neuralspot_edge as nse
-from ...defines import HKExportParams
-from ...utils import setup_logger
-from ..utils import load_datasets
-from .datasets import load_test_dataset
-logger = setup_logger(__name__)
+from ...defines import HKTaskParams
+from ...datasets import DatasetFactory
+from .datasets import load_test_dataset
-def export(params: HKExportParams):
+def export(params: HKTaskParams):
"""Export model
Args:
- params (HKExportParams): Deployment parameters
+ params (HKTaskParams): Deployment parameters
"""
-
os.makedirs(params.job_dir, exist_ok=True)
+ logger = nse.utils.setup_logger(__name__, level=params.verbose, file_path=params.job_dir / "export.log")
logger.debug(f"Creating working directory in {params.job_dir}")
- handler = logging.FileHandler(params.job_dir / "export.log", mode="w")
- handler.setLevel(logging.INFO)
- logger.addHandler(handler)
-
tfl_model_path = params.job_dir / "model.tflite"
tflm_model_path = params.job_dir / "model_buffer.h"
feat_shape = (params.frame_size, 1)
- class_shape = (params.frame_size, 1)
- ds_spec = (
- tf.TensorSpec(shape=feat_shape, dtype=tf.float32),
- tf.TensorSpec(shape=class_shape, dtype=tf.float32),
- )
+ datasets = [DatasetFactory.get(ds.name)(**ds.params) for ds in params.datasets]
- datasets = load_datasets(datasets=params.datasets)
-
- test_ds = load_test_dataset(datasets=datasets, params=params, ds_spec=ds_spec)
+ test_ds = load_test_dataset(datasets=datasets, params=params)
test_x, test_y = next(test_ds.batch(params.test_size).as_numpy_iterator())
# Load model and set fixed batch size of 1
logger.debug("Loading trained model")
model = nse.models.load_model(params.model_file)
-
- inputs = keras.Input(shape=ds_spec[0].shape, batch_size=1, name="input", dtype=ds_spec[0].dtype)
- model(inputs) # Build model with fixed batch size of 1
+ inputs = keras.Input(shape=feat_shape, batch_size=1, name="input", dtype="float32")
+ model(inputs)
flops = nse.metrics.flops.get_flops(model, batch_size=1, fpath=params.job_dir / "model_flops.log")
model.summary(print_fn=logger.info)
@@ -58,6 +41,7 @@ def export(params: HKExportParams):
logger.debug(f"Converting model to TFLite (quantization={params.quantization.mode})")
converter = nse.converters.tflite.TfLiteKerasConverter(model=model)
+
tflite_content = converter.convert(
test_x=test_x,
quantization=params.quantization.format,
@@ -83,25 +67,32 @@ def export(params: HKExportParams):
tflite.compile()
# Verify TFLite results match TF results on example data
- logger.debug("Validating model results")
+ metrics = [
+ keras.metrics.MeanAbsoluteError(name="mae"),
+ keras.metrics.MeanSquaredError(name="mse"),
+ keras.metrics.RootMeanSquaredError(name="rmse"),
+ ]
+
+ if params.val_metric not in [m.name for m in metrics]:
+ raise ValueError(f"Metric {params.val_metric} not supported")
+
+ logger.info("Validating model results")
y_true = test_y
y_pred_tf = model.predict(test_x)
y_pred_tfl = tflite.predict(x=test_x)
- tf_mae = np.mean(np.abs(y_true - y_pred_tf))
- tf_rmse = np.sqrt(np.mean((y_true - y_pred_tf) ** 2))
- logger.debug(f"[TF SET] MAE={tf_mae:.2%}, RMSE={tf_rmse:.2%}")
+ tf_rst = nse.metrics.compute_metrics(metrics, y_true, y_pred_tf)
+ tfl_rst = nse.metrics.compute_metrics(metrics, y_true, y_pred_tfl)
+ logger.info("[TF METRICS] " + " ".join([f"{k.upper()}={v:.2%}" for k, v in tf_rst.items()]))
+ logger.info("[TFL METRICS] " + " ".join([f"{k.upper()}={v:.2%}" for k, v in tfl_rst.items()]))
- tfl_mae = np.mean(np.abs(y_true - y_pred_tfl))
- tfl_rmse = np.sqrt(np.mean((y_true - y_pred_tfl) ** 2))
- logger.debug(f"[TFL SET] MAE={tfl_mae:.2%}, RMSE={tfl_rmse:.2%}")
+ metric_diff = abs(tf_rst[params.val_metric] - tfl_rst[params.val_metric])
# Check accuracy hit
- tfl_acc_drop = max(0, tf_mae - tfl_mae)
- if params.val_acc_threshold is not None and (1 - tfl_acc_drop) < params.val_acc_threshold:
- logger.warning(f"TFLite accuracy dropped by {tfl_acc_drop:0.2%}")
- elif params.val_acc_threshold:
- logger.debug(f"Validation passed ({tfl_acc_drop:0.2%})")
+ if params.val_metric_threshold is not None and metric_diff > params.val_metric_threshold:
+ logger.warning(f"TFLite accuracy dropped by {metric_diff:0.2%}")
+ elif params.val_metric_threshold:
+ logger.info(f"Validation passed ({metric_diff:0.2%})")
if params.tflm_file and tflm_model_path != params.tflm_file:
logger.debug(f"Copying TFLM header to {params.tflm_file}")
diff --git a/heartkit/tasks/translate/train.py b/heartkit/tasks/translate/train.py
index 75b48753..e7f335f6 100644
--- a/heartkit/tasks/translate/train.py
+++ b/heartkit/tasks/translate/train.py
@@ -1,42 +1,34 @@
-import logging
import os
import keras
-import tensorflow as tf
+import neuralspot_edge as nse
+import numpy as np
import wandb
from wandb.keras import WandbMetricsLogger, WandbModelCheckpoint
-import neuralspot_edge as nse
-from ...defines import HKTrainParams
-from ...utils import env_flag, set_random_seed, setup_logger
-from ..utils import load_datasets
+from ...defines import HKTaskParams
+from ...datasets import DatasetFactory
+from ...models import ModelFactory
from .datasets import load_train_datasets
-from .utils import create_model
-logger = setup_logger(__name__)
-
-def train(params: HKTrainParams):
+def train(params: HKTaskParams):
"""Train model
Args:
- params (HKTrainParams): Training parameters
+ params (HKTaskParams): Training parameters
"""
-
- params.seed = set_random_seed(params.seed)
- logger.debug(f"Random seed {params.seed}")
-
os.makedirs(params.job_dir, exist_ok=True)
+ logger = nse.utils.setup_logger(__name__, level=params.verbose, file_path=params.job_dir / "train.log")
logger.debug(f"Creating working directory in {params.job_dir}")
- handler = logging.FileHandler(params.job_dir / "train.log", mode="w")
- handler.setLevel(logging.INFO)
- logger.addHandler(handler)
+ params.seed = nse.utils.set_random_seed(params.seed)
+ logger.debug(f"Random seed {params.seed}")
with open(params.job_dir / "train_config.json", "w", encoding="utf-8") as fp:
fp.write(params.model_dump_json(indent=2))
- if env_flag("WANDB"):
+ if nse.utils.env_flag("WANDB"):
wandb.init(
project=params.project,
entity="ambiq",
@@ -46,54 +38,36 @@ def train(params: HKTrainParams):
# END IF
feat_shape = (params.frame_size, 1)
- class_shape = (params.frame_size, 1)
- class_shape = (128, 1)
- ds_spec = (
- tf.TensorSpec(shape=feat_shape, dtype="float32"),
- tf.TensorSpec(shape=class_shape, dtype="float32"),
- )
-
- datasets = load_datasets(datasets=params.datasets)
+ datasets = [DatasetFactory.get(ds.name)(**ds.params) for ds in params.datasets]
train_ds, val_ds = load_train_datasets(
datasets=datasets,
params=params,
- ds_spec=ds_spec,
)
- inputs = keras.Input(
- shape=ds_spec[0].shape,
- batch_size=None,
- name="input",
- dtype=ds_spec[0].dtype.name,
- )
+ inputs = keras.Input(shape=feat_shape, name="input", dtype="float32")
if params.resume and params.model_file:
logger.debug(f"Loading model from file {params.model_file}")
model = nse.models.load_model(params.model_file)
params.model_file = None
else:
logger.debug("Creating model from scratch")
- model = create_model(
- inputs,
+ model = ModelFactory.get(params.architecture.name)(
+ x=inputs,
+ params=params.architecture.params,
num_classes=params.num_classes,
- architecture=params.architecture,
)
# END IF
- if params.lr_cycles > 1:
- scheduler = keras.optimizers.schedules.CosineDecayRestarts(
- initial_learning_rate=params.lr_rate,
- first_decay_steps=int(0.1 * params.steps_per_epoch * params.epochs),
- t_mul=1.65 / (0.1 * params.lr_cycles * (params.lr_cycles - 1)),
- m_mul=0.4,
- )
- else:
- scheduler = keras.optimizers.schedules.CosineDecay(
- initial_learning_rate=params.lr_rate,
- decay_steps=params.steps_per_epoch * params.epochs,
- )
- # END IF
+ t_mul = 1
+ first_steps = (params.steps_per_epoch * params.epochs) / (np.power(params.lr_cycles, t_mul) - t_mul + 1)
+ scheduler = keras.optimizers.schedules.CosineDecayRestarts(
+ initial_learning_rate=params.lr_rate,
+ first_decay_steps=np.ceil(first_steps),
+ t_mul=t_mul,
+ m_mul=0.5,
+ )
optimizer = keras.optimizers.Adam(scheduler)
loss = keras.losses.MeanSquaredError()
@@ -118,7 +92,7 @@ def train(params: HKTrainParams):
logger.debug(f"Model requires {flops/1e6:0.2f} MFLOPS")
ModelCheckpoint = keras.callbacks.ModelCheckpoint
- if env_flag("WANDB"):
+ if nse.utils.env_flag("WANDB"):
ModelCheckpoint = WandbModelCheckpoint
model_callbacks = [
keras.callbacks.EarlyStopping(
@@ -126,6 +100,7 @@ def train(params: HKTrainParams):
patience=max(int(0.25 * params.epochs), 1),
mode="max" if params.val_metric == "f1" else "auto",
restore_best_weights=True,
+ verbose=params.verbose - 1,
),
ModelCheckpoint(
filepath=str(params.model_file),
@@ -133,25 +108,25 @@ def train(params: HKTrainParams):
save_best_only=True,
save_weights_only=False,
mode="max" if params.val_metric == "f1" else "auto",
- verbose=1,
+ verbose=params.verbose - 1,
),
keras.callbacks.CSVLogger(params.job_dir / "history.csv"),
]
- if env_flag("TENSORBOARD"):
+ if nse.utils.env_flag("TENSORBOARD"):
model_callbacks.append(
keras.callbacks.TensorBoard(
log_dir=params.job_dir,
write_steps_per_second=True,
)
)
- if env_flag("WANDB"):
+ if nse.utils.env_flag("WANDB"):
model_callbacks.append(WandbMetricsLogger())
try:
model.fit(
train_ds,
steps_per_epoch=params.steps_per_epoch,
- verbose=2,
+ verbose=params.verbose,
epochs=params.epochs,
validation_data=val_ds,
callbacks=model_callbacks,
@@ -162,5 +137,4 @@ def train(params: HKTrainParams):
logger.debug(f"Model saved to {params.model_file}")
# Get full validation results
- keras.models.load_model(params.model_file)
logger.debug("Performing full validation")
diff --git a/heartkit/tasks/translate/utils.py b/heartkit/tasks/translate/utils.py
deleted file mode 100644
index bc09e55d..00000000
--- a/heartkit/tasks/translate/utils.py
+++ /dev/null
@@ -1,107 +0,0 @@
-import keras
-from neuralspot_edge.models.tcn import Tcn, TcnBlockParams, TcnParams
-from rich.console import Console
-
-from ...defines import ModelArchitecture
-from ...models import ModelFactory
-
-console = Console()
-
-
-def create_model(inputs: keras.KerasTensor, num_classes: int, architecture: ModelArchitecture | None) -> keras.Model:
- """Generate model or use default
-
- Args:
- inputs (keras.KerasTensor): Model inputs
- num_classes (int): Number of classes
- architecture (ModelArchitecture|None): Model
-
- Returns:
- keras.Model: Model
- """
- if architecture:
- return ModelFactory.get(architecture.name)(
- x=inputs,
- params=architecture.params,
- num_classes=num_classes,
- )
-
- return _default_model(inputs=inputs, num_classes=num_classes)
-
-
-def _default_model(
- inputs: keras.KerasTensor,
- num_classes: int,
-) -> keras.Model:
- """Reference model
-
- Args:
- inputs (keras.KerasTensor): Model inputs
- num_classes (int): Number of classes
-
- Returns:
- keras.Model: Model
- """
- # Default model
-
- blocks = [
- TcnBlockParams(
- filters=8,
- kernel=(1, 7),
- dilation=(1, 1),
- dropout=0.1,
- ex_ratio=1,
- se_ratio=0,
- norm="batch",
- ),
- TcnBlockParams(
- filters=12,
- kernel=(1, 7),
- dilation=(1, 1),
- dropout=0.1,
- ex_ratio=1,
- se_ratio=2,
- norm="batch",
- ),
- TcnBlockParams(
- filters=16,
- kernel=(1, 7),
- dilation=(1, 2),
- dropout=0.1,
- ex_ratio=1,
- se_ratio=2,
- norm="batch",
- ),
- TcnBlockParams(
- filters=24,
- kernel=(1, 7),
- dilation=(1, 4),
- dropout=0.1,
- ex_ratio=1,
- se_ratio=2,
- norm="batch",
- ),
- TcnBlockParams(
- filters=32,
- kernel=(1, 7),
- dilation=(1, 8),
- dropout=0.1,
- ex_ratio=1,
- se_ratio=2,
- norm="batch",
- ),
- ]
-
- return Tcn(
- x=inputs,
- params=TcnParams(
- input_kernel=(1, 7),
- input_norm="batch",
- blocks=blocks,
- output_kernel=(1, 7),
- include_top=True,
- use_logits=True,
- model_name="tcn",
- ),
- num_classes=num_classes,
- )
diff --git a/heartkit/tasks/utils.py b/heartkit/tasks/utils.py
deleted file mode 100644
index 4675dac0..00000000
--- a/heartkit/tasks/utils.py
+++ /dev/null
@@ -1,22 +0,0 @@
-from ..datasets import DatasetFactory, HKDataset
-from ..defines import DatasetParams
-
-
-def load_datasets(
- datasets: list[DatasetParams] = None,
-) -> list[HKDataset]:
- """Load datasets
-
- Args:
- datasets (list[DatasetParams]): List of datasets
-
- Returns:
- HKDataset: Dataset
- """
- dsets = []
- for dset in datasets:
- if DatasetFactory.has(dset.name):
- dsets.append(DatasetFactory.get(dset.name)(ds_path=dset.path, **dset.params))
- # END IF
- # END FOR
- return dsets
diff --git a/heartkit/utils/__init__.py b/heartkit/utils/__init__.py
index 560cc9de..0a33f1ab 100644
--- a/heartkit/utils/__init__.py
+++ b/heartkit/utils/__init__.py
@@ -1,246 +1 @@
-import gzip
-import hashlib
-import logging
-
-import os
-import pickle
-from pathlib import Path
-from string import Template
-from typing import Any
-
-import numpy as np
-import requests
-from rich.logging import RichHandler
-from tqdm import tqdm
-
-from .factory import ItemFactory, create_factory
-
-
-def setup_logger(log_name: str, level: int | None = None) -> logging.Logger:
- """Setup logger with Rich
-
- Args:
- log_name (str): Logger name
-
- Returns:
- logging.Logger: Logger
- """
- new_logger = logging.getLogger(log_name)
- needs_init = not new_logger.handlers
-
- match level:
- case 0:
- log_level = logging.ERROR
- case 1:
- log_level = logging.INFO
- case 2 | 3 | 4:
- log_level = logging.DEBUG
- case None:
- log_level = None
- case _:
- log_level = logging.INFO
- # END MATCH
-
- if needs_init:
- logging.basicConfig(level=log_level, force=True, handlers=[RichHandler(rich_tracebacks=True)])
- new_logger.propagate = False
- new_logger.handlers = [RichHandler()]
-
- if log_level is not None:
- new_logger.setLevel(log_level)
-
- return new_logger
-
-
-logger = setup_logger(__name__)
-
-
-def set_random_seed(seed: int | None = None) -> int:
- """Set random seed across libraries: Keras, Numpy, Python
-
- Args:
- seed (int | None, optional): Random seed state to use. Defaults to None.
-
- Returns:
- int: Random seed
- """
- seed = seed or np.random.randint(2**16)
- try:
- import keras # pylint: disable=import-outside-toplevel
- except ImportError:
- pass
- else:
- keras.utils.set_random_seed(seed)
- return seed
-
-
-def load_pkl(file: str, compress: bool = True) -> dict[str, Any]:
- """Load pickled file.
-
- Args:
- file (str): File path (.pkl)
- compress (bool, optional): If file is compressed. Defaults to True.
-
- Returns:
- dict[str, Any]: Dictionary of pickled objects
- """
- if compress:
- with gzip.open(file, "rb") as fh:
- return pickle.load(fh)
- else:
- with open(file, "rb") as fh:
- return pickle.load(fh)
-
-
-def save_pkl(file: str, compress: bool = True, **kwargs):
- """Save python objects into pickle file.
-
- Args:
- file (str): File path (.pkl)
- compress (bool, optional): Whether to compress file. Defaults to True.
- """
- if compress:
- with gzip.open(file, "wb") as fh:
- pickle.dump(kwargs, fh, protocol=4)
- else:
- with open(file, "wb") as fh:
- pickle.dump(kwargs, fh, protocol=4)
-
-
-def env_flag(env_var: str, default: bool = False) -> bool:
- """Return the specified environment variable coerced to a bool, as follows:
- - When the variable is unset, or set to the empty string, return `default`.
- - When the variable is set to a truthy value, returns `True`.
- These are the truthy values:
- - 1
- - true, yes, on
- - When the variable is set to the anything else, returns False.
- Example falsy values:
- - 0
- - no
- - Ignore case and leading/trailing whitespace.
-
- Args:
- env_var (str): Environment variable name
- default (bool, optional): Default value. Defaults to False.
-
- Returns:
- bool: Value of environment variable
- """
- environ_string = os.environ.get(env_var, "").strip().lower()
- if not environ_string:
- return default
- return environ_string in ["1", "true", "yes", "on"]
-
-
-def compute_checksum(file: Path, checksum_type: str = "md5", chunk_size: int = 8192) -> str:
- """Compute checksum of file.
-
- Args:
- file (Path): File path
- checksum_type (str, optional): Checksum type. Defaults to "md5".
- chunk_size (int, optional): Chunk size. Defaults to 8192.
-
- Returns:
- str: Checksum value
- """
- if not file.is_file():
- raise FileNotFoundError(f"File {file} not found.")
- hash_algo = hashlib.new(checksum_type)
- with open(file, "rb") as f:
- for chunk in iter(lambda: f.read(chunk_size), b""):
- hash_algo.update(chunk)
- return hash_algo.hexdigest()
-
-
-def download_file(
- src: str,
- dst: Path,
- progress: bool = True,
- chunk_size: int = 8192,
- checksum: str | None = None,
- checksum_type: str = "size",
- timeout: int = 3600 * 24,
-):
- """Download file from supplied url to destination streaming.
-
- checksum: hd5, sha256, sha512, size
-
- Args:
- src (str): Source URL path
- dst (PathLike): Destination file path
- progress (bool, optional): Display progress bar. Defaults to True.
- chunk_size (int, optional): Chunk size. Defaults to 8192.
- checksum (str|None, optional): Checksum value. Defaults to None.
- checksum_type (str|None, optional): Checksum type or size. Defaults to None.
-
- Raises:
- ValueError: If checksum doesn't match
-
-
- """
-
- # If file exists and checksum matches, skip download
- if dst.is_file() and checksum:
- match checksum_type:
- case "size":
- # Get number of bytes in file
- calculated_checksum = str(dst.stat().st_size)
- case _:
- calculated_checksum = compute_checksum(dst, checksum_type, chunk_size)
- if calculated_checksum == checksum:
- logger.debug(f"File {dst} already exists and checksum matches. Skipping...")
- return
- # END IF
- # END IF
-
- # Create parent directory if not exists
- dst.parent.mkdir(parents=True, exist_ok=True)
-
- # Download file in chunks
- with requests.get(src, stream=True, timeout=timeout) as r:
- r.raise_for_status()
- req_len = int(r.headers.get("Content-length", 0))
- prog_bar = tqdm(total=req_len, unit="iB", unit_scale=True) if progress else None
- with open(dst, "wb") as f:
- for chunk in r.iter_content(chunk_size=chunk_size):
- f.write(chunk)
- if prog_bar:
- prog_bar.update(len(chunk))
- # END FOR
- # END WITH
- # END WITH
-
-
-def resolve_template_path(fpath: Path, **kwargs: Any) -> Path:
- """Resolve templated path w/ supplied substitutions.
-
- Args:
- fpath (Path): File path
- **kwargs (Any): Template arguments
-
- Returns:
- Path: Resolved file path
- """
- return Path(Template(str(fpath)).safe_substitute(**kwargs))
-
-
-def silence_tensorflow():
- """Silence every unnecessary warning from tensorflow."""
- logging.getLogger("tensorflow").setLevel(logging.ERROR)
- os.environ["KMP_AFFINITY"] = "noverbose"
- os.environ["TF_CPP_MIN_LOG_LEVEL"] = "3"
- os.environ["AUTOGRAPH_VERBOSITY"] = "5"
- # We wrap this inside a try-except block
- # because we do not want to be the one package
- # that crashes when TensorFlow is not installed
- # when we are the only package that requires it
- # in a given Jupyter Notebook, such as when the
- # package import is simply copy-pasted.
- try:
- import tensorflow as tf
-
- tf.get_logger().setLevel("ERROR")
- tf.autograph.set_verbosity(3)
- except ModuleNotFoundError:
- pass
+from .plotting import setup_plotting, light_theme, dark_theme
diff --git a/heartkit/utils/factory.py b/heartkit/utils/factory.py
deleted file mode 100644
index d25fa2f7..00000000
--- a/heartkit/utils/factory.py
+++ /dev/null
@@ -1,105 +0,0 @@
-from typing import TypeVar, Generic, Type
-from threading import Lock
-
-T = TypeVar("T")
-
-
-class SingletonMeta(type):
- """Thread-safe singleton."""
-
- _instances = {}
- _lock: Lock = Lock()
-
- def __call__(cls, *args, **kwargs):
- with cls._lock:
- if "singleton" in kwargs:
- instance_name = kwargs.get("singleton")
- del kwargs["singleton"]
- else:
- instance_name = cls
- # END IF
- if instance_name not in cls._instances:
- instance = super().__call__(*args, **kwargs)
- cls._instances[instance_name] = instance
- return cls._instances[instance_name]
-
-
-class ItemFactory(Generic[T], metaclass=SingletonMeta):
- """Dataset factory enables registering, creating, and listing datasets. It is a singleton class."""
-
- _items: dict[str, T]
-
- def __init__(self):
- self._items = {}
-
- def __call__(cls, *args, **kwargs):
- return super().__call__(*args, **kwargs)
-
- @classmethod
- def shared(cls, factory: str):
- """Get the shared instance of the factory
-
- Returns:
- ItemFactory: shared instance
- """
- return cls(singleton=factory)
-
- def register(self, name: str, item: T) -> None:
- """Register an item
-
- Args:
- name (str): Unique item name
- item (T): Item
- """
- self._items[name] = item
-
- def unregister(self, name: str) -> None:
- """Unregister an item
-
- Args:
- name (str): Item name
- """
- self._items.pop(name, None)
-
- def list(self) -> list[str]:
- """List registered items
-
- Returns:
- list[str]: item names
- """
- return list(self._items.keys())
-
- def get(self, name: str) -> T:
- """Get an item
-
- Args:
- name (str): Item name
-
- Returns:
- HKDataset: dataset
- """
- return self._items[name]
-
- def has(self, name: str) -> bool:
- """Check if an item is registered
-
- Args:
- name (str): Item name
-
- Returns:
- bool: True if dataset is registered
- """
- return name in self._items
-
-
-def create_factory(factory: str, type: Type[T]) -> ItemFactory[T]:
- """Create a factory
-
- Args:
- factory (str): Factory name
- type (Type[T]): Item type
-
- Returns:
- ItemFactory[T]: factory
- """
- return ItemFactory[T].shared(factory)
diff --git a/heartkit/utils/plotting.py b/heartkit/utils/plotting.py
new file mode 100644
index 00000000..e033ecb8
--- /dev/null
+++ b/heartkit/utils/plotting.py
@@ -0,0 +1,67 @@
+import dataclasses
+import matplotlib as mpl
+import plotly.io as pio
+import matplotlib.pyplot as plt
+
+
+@dataclasses.dataclass
+class PlotPallette:
+ bg_rgba_color: str = "rgba(38,42,50,1.0)"
+ bg_color: str = "#262a32"
+ primary_color: str = "#11acd5"
+ secondary_color: str = "#ce6cff"
+ tertiary_color: str = "#ea3424"
+ quaternary_color: str = "#5cc99a"
+ plotly_template: str = "plotly_dark"
+ matplot_template: str = "dark_background"
+
+ @property
+ def colors(self):
+ return [self.primary_color, self.secondary_color, self.tertiary_color, self.quaternary_color]
+
+
+# Make a light theme and a dark theme
+light_theme = PlotPallette(
+ bg_rgba_color="rgba(255,255,255,1.0)",
+ bg_color="#ffffff",
+ primary_color="#11acd5",
+ secondary_color="#ce6cff",
+ tertiary_color="#ea3424",
+ quaternary_color="#5cc99a",
+ plotly_template="plotly",
+ matplot_template="default",
+)
+
+dark_theme = PlotPallette(
+ bg_rgba_color="rgba(38,42,50,1.0)",
+ bg_color="#262a32",
+ primary_color="#11acd5",
+ secondary_color="#ce6cff",
+ tertiary_color="#ea3424",
+ quaternary_color="#5cc99a",
+ plotly_template="plotly_dark",
+ matplot_template="dark_background",
+)
+
+
+def setup_plotting(theme: PlotPallette = dark_theme):
+ """Setup plotting environment for matplotlib and plotly
+
+ Args:
+ theme (PlotPallette, optional): Plotting theme. Defaults to dark_theme.
+ """
+ SMALL_SIZE = 12
+ MEDIUM_SIZE = 14
+ BIGGER_SIZE = 16
+
+ pio.renderers.default = "notebook"
+ plt.style.use(theme.matplot_template)
+ mpl.rcParams["axes.facecolor"] = theme.bg_color
+ mpl.rcParams["figure.facecolor"] = theme.bg_color
+ plt.rc("font", size=SMALL_SIZE) # controls default text sizes
+ plt.rc("axes", titlesize=SMALL_SIZE) # fontsize of the axes title
+ plt.rc("axes", labelsize=MEDIUM_SIZE) # fontsize of the x and y labels
+ plt.rc("xtick", labelsize=SMALL_SIZE) # fontsize of the tick labels
+ plt.rc("ytick", labelsize=SMALL_SIZE) # fontsize of the tick labels
+ plt.rc("legend", fontsize=SMALL_SIZE) # legend fontsize
+ plt.rc("figure", titlesize=BIGGER_SIZE) # fontsize of the figure title
diff --git a/mkdocs.yml b/mkdocs.yml
index 1f411399..672141ba 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -10,14 +10,6 @@ nav:
- Home:
- Home: index.md
- Quickstart: quickstart.md
- - Modes:
- - modes/index.md
- - Configuration: modes/configuration.md
- - Download: modes/download.md
- - Train: modes/train.md
- - Evaluate: modes/evaluate.md
- - Export: modes/export.md
- - Demo: modes/demo.md
- Tasks:
- tasks/index.md
- Denoise: tasks/denoise.md
@@ -26,14 +18,24 @@ nav:
- Beat: tasks/beat.md
# - Diagnostic: tasks/diagnostic.md
- BYOT: tasks/byot.md
- - Model Zoo:
- - zoo/index.md
- - Models:
- - models/index.md
+ - Modes:
+ - modes/index.md
+ - Configuration: modes/configuration.md
+ - Download: modes/download.md
+ - Train: modes/train.md
+ - Evaluate: modes/evaluate.md
+ - Export: modes/export.md
+ - Demo: modes/demo.md
- Datasets:
- datasets/index.md
+ - Models:
+ - models/index.md
+ - Model Zoo:
+ - zoo/index.md
- Guides:
- guides/index.md
+ - API:
+ - api/index.md
- Quickstart:
- quickstart.md
@@ -41,15 +43,6 @@ nav:
- CLI: usage/cli.md
- Python: usage/python.md
- - Modes:
- - modes/index.md
- - Configuration: modes/configuration.md
- - Download: modes/download.md
- - Train: modes/train.md
- - Evaluate: modes/evaluate.md
- - Export: modes/export.md
- - Demo: modes/demo.md
-
- Tasks:
- tasks/index.md
- Denoise: tasks/denoise.md
@@ -59,16 +52,14 @@ nav:
# - Diagnostic: tasks/diagnostic.md
- BYOT: tasks/byot.md
- - Model Zoo:
- - zoo/index.md
- - Denoise: zoo/denoise.md
- - Segmentation: zoo/segmentation.md
- - Rhythm: zoo/rhythm.md
- - Beat: zoo/beat.md
- # - Diagnostic: zoo/diagnostic.md
-
- - Models:
- - models/index.md
+ - Modes:
+ - modes/index.md
+ - Configuration: modes/configuration.md
+ - Download: modes/download.md
+ - Train: modes/train.md
+ - Evaluate: modes/evaluate.md
+ - Export: modes/export.md
+ - Demo: modes/demo.md
- Datasets:
- datasets/index.md
@@ -81,6 +72,18 @@ nav:
- MIT-BIH: datasets/mitbih.md
- BYOD: datasets/byod.md
+ - Models:
+ - models/index.md
+ - BYOM: models/byom.md
+
+ - Model Zoo:
+ - zoo/index.md
+ - Denoise: zoo/denoise.md
+ - Segmentation: zoo/segmentation.md
+ - Rhythm: zoo/rhythm.md
+ - Beat: zoo/beat.md
+ # - Diagnostic: zoo/diagnostic.md
+
- Guides:
- guides/index.md
- EVB Setup: guides/evb-setup.md
@@ -90,11 +93,31 @@ nav:
- Train ECG Denoiser: guides/train-ecg-denoiser.ipynb
- Train ECG Segmentation Model: guides/train-ecg-segmentation.ipynb
- - Reference:
+ - API:
- HeartKit: api/heartkit.md
- - Datasets: api/datasets.md
- - Models: api/models.md
- - Tasks: api/tasks.md
+ - Datasets:
+ - Dataset: api/datasets/dataset.md
+ - DatasetFactory: api/datasets/factory.md
+ - Dataloader: api/datasets/dataloader.md
+ - Augmentations: api/datasets/augmentation.md
+ - Synthetic: api/datasets/synthetic.md
+ - Icentia11k: api/datasets/icentia11k.md
+ - QTDB: api/datasets/qtdb.md
+ - LUDB: api/datasets/ludb.md
+ - LSAD: api/datasets/lsad.md
+ - PTB-XL: api/datasets/ptbxl.md
+ - Models:
+ - Model: api/models/model.md
+ - ModelFactory: api/models/factory.md
+ - Tasks:
+ - Task: api/tasks/task.md
+ - TaskFactory: api/tasks/factory.md
+ - Beat: api/tasks/beat.md
+ - Denoise: api/tasks/denoise.md
+ - Foundation: api/tasks/foundation.md
+ - Segmentation: api/tasks/segmentation.md
+ - Rhythm: api/tasks/rhythm.md
+
theme:
name: material
@@ -139,12 +162,14 @@ theme:
- navigation.tabs
- navigation.tabs.sticky
- navigation.prune
+ - navigation.path
+
- navigation.footer
- navigation.tracking
- navigation.instant
- navigation.instant.progress
- navigation.indexes
- - navigation.sections # navigation.expand or navigation.sections
+ - navigation.expand # navigation.expand or navigation.sections
- content.tabs.link # all code tabs change simultaneously
plugins:
@@ -156,10 +181,28 @@ plugins:
- https://docs.python.org/3/objects.inv
- https://numpy.org/doc/stable/objects.inv
options:
+ show_bases: false
+ show_root_heading: false
+ parameter_headings: true
+ show_root_toc_entry: false
+ show_symbol_type_toc: false
+ group_by_category: true
+ show_category_heading: true
docstring_style: google
- docstring_section_style: list
- line_length: 92
- show_root_heading: true
+ docstring_section_style: table
+ members_order: source
+ filters: ["!^_", "^__init__$"]
+ line_length: 120
+ heading_level: 3
+ merge_init_into_class: true
+ show_root_full_path: false
+ show_symbol_type_heading: false
+ modernize_annotations: true
+ show_signature: true
+ show_signature_annotations: false
+ separate_signature: false
+ show_source: true
+
- mkdocs-jupyter:
include_requirejs: true
include_source: true
diff --git a/poetry.lock b/poetry.lock
index 391b6fbf..f991951e 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -66,32 +66,33 @@ files = [
[[package]]
name = "argdantic"
-version = "1.1.0"
+version = "1.3.0"
description = "Typed command line interfaces with argparse and pydantic"
optional = false
python-versions = "*"
files = [
- {file = "argdantic-1.1.0-py2.py3-none-any.whl", hash = "sha256:0ddabe399bf9577fcd73c02aa484c0b66be95b1d1d53ed536c7738b5fe5c3726"},
- {file = "argdantic-1.1.0.tar.gz", hash = "sha256:6778204f926cb7d23aa1eaedf34d374a4d137977c8699843d7e1d2684df571ed"},
+ {file = "argdantic-1.3.0-py2.py3-none-any.whl", hash = "sha256:a6d53d4dd9d2bc24fd891bab020a46675d86910c39dbe8e8154684110a1898b2"},
+ {file = "argdantic-1.3.0.tar.gz", hash = "sha256:abf79612e18f0d4d0e9cf04227d4e1d9428de053f132a675e64faa7fa6db8a99"},
]
[package.dependencies]
-orjson = {version = ">=3.9.0,<4.0", optional = true, markers = "extra == \"all\""}
-pydantic = ">=2.3.0,<3.0"
-pydantic-settings = ">=2.0.0,<3"
+orjson = {version = ">=3.10.0,<4.0", optional = true, markers = "extra == \"all\""}
+pydantic = ">=2.8.0,<3.0"
+pydantic-settings = ">=2.4.0,<3"
python-dotenv = {version = ">=1.0.0,<2.0", optional = true, markers = "extra == \"all\""}
pyyaml = {version = ">=6.0.0,<7.0", optional = true, markers = "extra == \"all\""}
+toml = {version = ">=0.10.0,<1.0", optional = true, markers = "extra == \"all\""}
tomli = {version = ">=2.0,<3.0", optional = true, markers = "extra == \"all\""}
tomli-w = {version = ">=1.0.0,<2.0", optional = true, markers = "extra == \"all\""}
[package.extras]
-all = ["orjson (>=3.9.0,<4.0)", "python-dotenv (>=1.0.0,<2.0)", "pyyaml (>=6.0.0,<7.0)", "tomli (>=2.0,<3.0)", "tomli-w (>=1.0.0,<2.0)"]
-dev = ["black (>=23.9.0,<24.0)", "flake8 (>=6.1.0,<7.0)", "isort (>=5.10.0,<6.0)"]
-docs = ["mdx-include (>=1.4.0,<2.0)", "mkdocs (>=1.5.0,<2.0)", "mkdocs-material (>=9.3.0,<10.0)"]
+all = ["orjson (>=3.10.0,<4.0)", "python-dotenv (>=1.0.0,<2.0)", "pyyaml (>=6.0.0,<7.0)", "toml (>=0.10.0,<1.0)", "tomli (>=2.0,<3.0)", "tomli-w (>=1.0.0,<2.0)"]
+dev = ["flit (>=3.9.0,<4.0)", "ruff (>=0.5.6,<1.0)"]
+docs = ["mdx-include (>=1.4.0,<2.0)", "mkdocs (>=1.6.0,<2.0)", "mkdocs-material (>=9.5.0,<10.0)"]
env = ["python-dotenv (>=1.0.0,<2.0)"]
-json = ["orjson (>=3.9.0,<4.0)"]
-test = ["coverage (>=7.3.0,<8.0)", "mock (>=5.1.0,<6.0)", "pytest (>=6.2.5,<7.0)", "pytest-cov (>=4.1.0,<5.0)", "pytest-xdist (>=3.3.0,<4.0)"]
-toml = ["tomli (>=2.0,<3.0)", "tomli-w (>=1.0.0,<2.0)"]
+json = ["orjson (>=3.10.0,<4.0)"]
+test = ["coverage (>=7.6.0,<8.0)", "mock (>=5.1.0,<6.0)", "pytest (>=8.3.0,<9.0)", "pytest-cov (>=5.0.0,<6.0)", "pytest-xdist (>=3.6.0,<4.0)"]
+toml = ["toml (>=0.10.0,<1.0)", "tomli (>=2.0,<3.0)", "tomli-w (>=1.0.0,<2.0)"]
yaml = ["pyyaml (>=6.0.0,<7.0)"]
[[package]]
@@ -216,32 +217,32 @@ files = [
[[package]]
name = "attrs"
-version = "23.2.0"
+version = "24.2.0"
description = "Classes Without Boilerplate"
optional = false
python-versions = ">=3.7"
files = [
- {file = "attrs-23.2.0-py3-none-any.whl", hash = "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1"},
- {file = "attrs-23.2.0.tar.gz", hash = "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30"},
+ {file = "attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2"},
+ {file = "attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346"},
]
[package.extras]
-cov = ["attrs[tests]", "coverage[toml] (>=5.3)"]
-dev = ["attrs[tests]", "pre-commit"]
-docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"]
-tests = ["attrs[tests-no-zope]", "zope-interface"]
-tests-mypy = ["mypy (>=1.6)", "pytest-mypy-plugins"]
-tests-no-zope = ["attrs[tests-mypy]", "cloudpickle", "hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist[psutil]"]
+benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"]
+cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"]
+dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"]
+docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"]
+tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"]
+tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"]
[[package]]
name = "babel"
-version = "2.15.0"
+version = "2.16.0"
description = "Internationalization utilities"
optional = false
python-versions = ">=3.8"
files = [
- {file = "Babel-2.15.0-py3-none-any.whl", hash = "sha256:08706bdad8d0a3413266ab61bd6c34d0c28d6e1e7badf40a2cebe67644e2e1fb"},
- {file = "babel-2.15.0.tar.gz", hash = "sha256:8daf0e265d05768bc6c7a314cf1321e9a123afc328cc635c18622a2f30a04413"},
+ {file = "babel-2.16.0-py3-none-any.whl", hash = "sha256:368b5b98b37c06b7daf6696391c3240c938b37767d4584413e8438c5c435fa8b"},
+ {file = "babel-2.16.0.tar.gz", hash = "sha256:d1f3554ca26605fe173f3de0c65f750f5a42f924499bf134de6423582298e316"},
]
[package.extras]
@@ -288,17 +289,17 @@ css = ["tinycss2 (>=1.1.0,<1.3)"]
[[package]]
name = "boto3"
-version = "1.34.144"
+version = "1.34.158"
description = "The AWS SDK for Python"
optional = false
python-versions = ">=3.8"
files = [
- {file = "boto3-1.34.144-py3-none-any.whl", hash = "sha256:b8433d481d50b68a0162c0379c0dd4aabfc3d1ad901800beb5b87815997511c1"},
- {file = "boto3-1.34.144.tar.gz", hash = "sha256:2f3e88b10b8fcc5f6100a9d74cd28230edc9d4fa226d99dd40a3ab38ac213673"},
+ {file = "boto3-1.34.158-py3-none-any.whl", hash = "sha256:c29e9b7e1034e8734ccaffb9f2b3f3df2268022fd8a93d836604019f8759ce27"},
+ {file = "boto3-1.34.158.tar.gz", hash = "sha256:5b7b2ce0ec1e498933f600d29f3e1c641f8c44dd7e468c26795359d23d81fa39"},
]
[package.dependencies]
-botocore = ">=1.34.144,<1.35.0"
+botocore = ">=1.34.158,<1.35.0"
jmespath = ">=0.7.1,<2.0.0"
s3transfer = ">=0.10.0,<0.11.0"
@@ -307,13 +308,13 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"]
[[package]]
name = "botocore"
-version = "1.34.144"
+version = "1.34.158"
description = "Low-level, data-driven core of boto 3."
optional = false
python-versions = ">=3.8"
files = [
- {file = "botocore-1.34.144-py3-none-any.whl", hash = "sha256:a2cf26e1bf10d5917a2285e50257bc44e94a1d16574f282f3274f7a5d8d1f08b"},
- {file = "botocore-1.34.144.tar.gz", hash = "sha256:4215db28d25309d59c99507f1f77df9089e5bebbad35f6e19c7c44ec5383a3e8"},
+ {file = "botocore-1.34.158-py3-none-any.whl", hash = "sha256:0e6fceba1e39bfa8feeba70ba3ac2af958b3387df4bd3b5f2db3f64c1754c756"},
+ {file = "botocore-1.34.158.tar.gz", hash = "sha256:5934082e25ad726673afbf466092fb1223dafa250e6e756c819430ba6b1b3da5"},
]
[package.dependencies]
@@ -322,7 +323,7 @@ python-dateutil = ">=2.1,<3.0.0"
urllib3 = {version = ">=1.25.4,<2.2.0 || >2.2.0,<3", markers = "python_version >= \"3.10\""}
[package.extras]
-crt = ["awscrt (==0.20.11)"]
+crt = ["awscrt (==0.21.2)"]
[[package]]
name = "certifi"
@@ -337,63 +338,78 @@ files = [
[[package]]
name = "cffi"
-version = "1.16.0"
+version = "1.17.0"
description = "Foreign Function Interface for Python calling C code."
optional = false
python-versions = ">=3.8"
files = [
- {file = "cffi-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6b3d6606d369fc1da4fd8c357d026317fbb9c9b75d36dc16e90e84c26854b088"},
- {file = "cffi-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ac0f5edd2360eea2f1daa9e26a41db02dd4b0451b48f7c318e217ee092a213e9"},
- {file = "cffi-1.16.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7e61e3e4fa664a8588aa25c883eab612a188c725755afff6289454d6362b9673"},
- {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a72e8961a86d19bdb45851d8f1f08b041ea37d2bd8d4fd19903bc3083d80c896"},
- {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b50bf3f55561dac5438f8e70bfcdfd74543fd60df5fa5f62d94e5867deca684"},
- {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7651c50c8c5ef7bdb41108b7b8c5a83013bfaa8a935590c5d74627c047a583c7"},
- {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4108df7fe9b707191e55f33efbcb2d81928e10cea45527879a4749cbe472614"},
- {file = "cffi-1.16.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:32c68ef735dbe5857c810328cb2481e24722a59a2003018885514d4c09af9743"},
- {file = "cffi-1.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:673739cb539f8cdaa07d92d02efa93c9ccf87e345b9a0b556e3ecc666718468d"},
- {file = "cffi-1.16.0-cp310-cp310-win32.whl", hash = "sha256:9f90389693731ff1f659e55c7d1640e2ec43ff725cc61b04b2f9c6d8d017df6a"},
- {file = "cffi-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:e6024675e67af929088fda399b2094574609396b1decb609c55fa58b028a32a1"},
- {file = "cffi-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b84834d0cf97e7d27dd5b7f3aca7b6e9263c56308ab9dc8aae9784abb774d404"},
- {file = "cffi-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b8ebc27c014c59692bb2664c7d13ce7a6e9a629be20e54e7271fa696ff2b417"},
- {file = "cffi-1.16.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee07e47c12890ef248766a6e55bd38ebfb2bb8edd4142d56db91b21ea68b7627"},
- {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8a9d3ebe49f084ad71f9269834ceccbf398253c9fac910c4fd7053ff1386936"},
- {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e70f54f1796669ef691ca07d046cd81a29cb4deb1e5f942003f401c0c4a2695d"},
- {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5bf44d66cdf9e893637896c7faa22298baebcd18d1ddb6d2626a6e39793a1d56"},
- {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b78010e7b97fef4bee1e896df8a4bbb6712b7f05b7ef630f9d1da00f6444d2e"},
- {file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c6a164aa47843fb1b01e941d385aab7215563bb8816d80ff3a363a9f8448a8dc"},
- {file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e09f3ff613345df5e8c3667da1d918f9149bd623cd9070c983c013792a9a62eb"},
- {file = "cffi-1.16.0-cp311-cp311-win32.whl", hash = "sha256:2c56b361916f390cd758a57f2e16233eb4f64bcbeee88a4881ea90fca14dc6ab"},
- {file = "cffi-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:db8e577c19c0fda0beb7e0d4e09e0ba74b1e4c092e0e40bfa12fe05b6f6d75ba"},
- {file = "cffi-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:fa3a0128b152627161ce47201262d3140edb5a5c3da88d73a1b790a959126956"},
- {file = "cffi-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:68e7c44931cc171c54ccb702482e9fc723192e88d25a0e133edd7aff8fcd1f6e"},
- {file = "cffi-1.16.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abd808f9c129ba2beda4cfc53bde801e5bcf9d6e0f22f095e45327c038bfe68e"},
- {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88e2b3c14bdb32e440be531ade29d3c50a1a59cd4e51b1dd8b0865c54ea5d2e2"},
- {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcc8eb6d5902bb1cf6dc4f187ee3ea80a1eba0a89aba40a5cb20a5087d961357"},
- {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7be2d771cdba2942e13215c4e340bfd76398e9227ad10402a8767ab1865d2e6"},
- {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e715596e683d2ce000574bae5d07bd522c781a822866c20495e52520564f0969"},
- {file = "cffi-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2d92b25dbf6cae33f65005baf472d2c245c050b1ce709cc4588cdcdd5495b520"},
- {file = "cffi-1.16.0-cp312-cp312-win32.whl", hash = "sha256:b2ca4e77f9f47c55c194982e10f058db063937845bb2b7a86c84a6cfe0aefa8b"},
- {file = "cffi-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:68678abf380b42ce21a5f2abde8efee05c114c2fdb2e9eef2efdb0257fba1235"},
- {file = "cffi-1.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0c9ef6ff37e974b73c25eecc13952c55bceed9112be2d9d938ded8e856138bcc"},
- {file = "cffi-1.16.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a09582f178759ee8128d9270cd1344154fd473bb77d94ce0aeb2a93ebf0feaf0"},
- {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e760191dd42581e023a68b758769e2da259b5d52e3103c6060ddc02c9edb8d7b"},
- {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80876338e19c951fdfed6198e70bc88f1c9758b94578d5a7c4c91a87af3cf31c"},
- {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a6a14b17d7e17fa0d207ac08642c8820f84f25ce17a442fd15e27ea18d67c59b"},
- {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6602bc8dc6f3a9e02b6c22c4fc1e47aa50f8f8e6d3f78a5e16ac33ef5fefa324"},
- {file = "cffi-1.16.0-cp38-cp38-win32.whl", hash = "sha256:131fd094d1065b19540c3d72594260f118b231090295d8c34e19a7bbcf2e860a"},
- {file = "cffi-1.16.0-cp38-cp38-win_amd64.whl", hash = "sha256:31d13b0f99e0836b7ff893d37af07366ebc90b678b6664c955b54561fc36ef36"},
- {file = "cffi-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:582215a0e9adbe0e379761260553ba11c58943e4bbe9c36430c4ca6ac74b15ed"},
- {file = "cffi-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b29ebffcf550f9da55bec9e02ad430c992a87e5f512cd63388abb76f1036d8d2"},
- {file = "cffi-1.16.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc9b18bf40cc75f66f40a7379f6a9513244fe33c0e8aa72e2d56b0196a7ef872"},
- {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cb4a35b3642fc5c005a6755a5d17c6c8b6bcb6981baf81cea8bfbc8903e8ba8"},
- {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b86851a328eedc692acf81fb05444bdf1891747c25af7529e39ddafaf68a4f3f"},
- {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c0f31130ebc2d37cdd8e44605fb5fa7ad59049298b3f745c74fa74c62fbfcfc4"},
- {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f8e709127c6c77446a8c0a8c8bf3c8ee706a06cd44b1e827c3e6a2ee6b8c098"},
- {file = "cffi-1.16.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:748dcd1e3d3d7cd5443ef03ce8685043294ad6bd7c02a38d1bd367cfd968e000"},
- {file = "cffi-1.16.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8895613bcc094d4a1b2dbe179d88d7fb4a15cee43c052e8885783fac397d91fe"},
- {file = "cffi-1.16.0-cp39-cp39-win32.whl", hash = "sha256:ed86a35631f7bfbb28e108dd96773b9d5a6ce4811cf6ea468bb6a359b256b1e4"},
- {file = "cffi-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:3686dffb02459559c74dd3d81748269ffb0eb027c39a6fc99502de37d501faa8"},
- {file = "cffi-1.16.0.tar.gz", hash = "sha256:bcb3ef43e58665bbda2fb198698fcae6776483e0c4a631aa5647806c25e02cc0"},
+ {file = "cffi-1.17.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f9338cc05451f1942d0d8203ec2c346c830f8e86469903d5126c1f0a13a2bcbb"},
+ {file = "cffi-1.17.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a0ce71725cacc9ebf839630772b07eeec220cbb5f03be1399e0457a1464f8e1a"},
+ {file = "cffi-1.17.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c815270206f983309915a6844fe994b2fa47e5d05c4c4cef267c3b30e34dbe42"},
+ {file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6bdcd415ba87846fd317bee0774e412e8792832e7805938987e4ede1d13046d"},
+ {file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a98748ed1a1df4ee1d6f927e151ed6c1a09d5ec21684de879c7ea6aa96f58f2"},
+ {file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0a048d4f6630113e54bb4b77e315e1ba32a5a31512c31a273807d0027a7e69ab"},
+ {file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24aa705a5f5bd3a8bcfa4d123f03413de5d86e497435693b638cbffb7d5d8a1b"},
+ {file = "cffi-1.17.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:856bf0924d24e7f93b8aee12a3a1095c34085600aa805693fb7f5d1962393206"},
+ {file = "cffi-1.17.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:4304d4416ff032ed50ad6bb87416d802e67139e31c0bde4628f36a47a3164bfa"},
+ {file = "cffi-1.17.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:331ad15c39c9fe9186ceaf87203a9ecf5ae0ba2538c9e898e3a6967e8ad3db6f"},
+ {file = "cffi-1.17.0-cp310-cp310-win32.whl", hash = "sha256:669b29a9eca6146465cc574659058ed949748f0809a2582d1f1a324eb91054dc"},
+ {file = "cffi-1.17.0-cp310-cp310-win_amd64.whl", hash = "sha256:48b389b1fd5144603d61d752afd7167dfd205973a43151ae5045b35793232aa2"},
+ {file = "cffi-1.17.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c5d97162c196ce54af6700949ddf9409e9833ef1003b4741c2b39ef46f1d9720"},
+ {file = "cffi-1.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5ba5c243f4004c750836f81606a9fcb7841f8874ad8f3bf204ff5e56332b72b9"},
+ {file = "cffi-1.17.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bb9333f58fc3a2296fb1d54576138d4cf5d496a2cc118422bd77835e6ae0b9cb"},
+ {file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:435a22d00ec7d7ea533db494da8581b05977f9c37338c80bc86314bec2619424"},
+ {file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d1df34588123fcc88c872f5acb6f74ae59e9d182a2707097f9e28275ec26a12d"},
+ {file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df8bb0010fdd0a743b7542589223a2816bdde4d94bb5ad67884348fa2c1c67e8"},
+ {file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8b5b9712783415695663bd463990e2f00c6750562e6ad1d28e072a611c5f2a6"},
+ {file = "cffi-1.17.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ffef8fd58a36fb5f1196919638f73dd3ae0db1a878982b27a9a5a176ede4ba91"},
+ {file = "cffi-1.17.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4e67d26532bfd8b7f7c05d5a766d6f437b362c1bf203a3a5ce3593a645e870b8"},
+ {file = "cffi-1.17.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:45f7cd36186db767d803b1473b3c659d57a23b5fa491ad83c6d40f2af58e4dbb"},
+ {file = "cffi-1.17.0-cp311-cp311-win32.whl", hash = "sha256:a9015f5b8af1bb6837a3fcb0cdf3b874fe3385ff6274e8b7925d81ccaec3c5c9"},
+ {file = "cffi-1.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:b50aaac7d05c2c26dfd50c3321199f019ba76bb650e346a6ef3616306eed67b0"},
+ {file = "cffi-1.17.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aec510255ce690d240f7cb23d7114f6b351c733a74c279a84def763660a2c3bc"},
+ {file = "cffi-1.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2770bb0d5e3cc0e31e7318db06efcbcdb7b31bcb1a70086d3177692a02256f59"},
+ {file = "cffi-1.17.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db9a30ec064129d605d0f1aedc93e00894b9334ec74ba9c6bdd08147434b33eb"},
+ {file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a47eef975d2b8b721775a0fa286f50eab535b9d56c70a6e62842134cf7841195"},
+ {file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3e0992f23bbb0be00a921eae5363329253c3b86287db27092461c887b791e5e"},
+ {file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6107e445faf057c118d5050560695e46d272e5301feffda3c41849641222a828"},
+ {file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb862356ee9391dc5a0b3cbc00f416b48c1b9a52d252d898e5b7696a5f9fe150"},
+ {file = "cffi-1.17.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c1c13185b90bbd3f8b5963cd8ce7ad4ff441924c31e23c975cb150e27c2bf67a"},
+ {file = "cffi-1.17.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:17c6d6d3260c7f2d94f657e6872591fe8733872a86ed1345bda872cfc8c74885"},
+ {file = "cffi-1.17.0-cp312-cp312-win32.whl", hash = "sha256:c3b8bd3133cd50f6b637bb4322822c94c5ce4bf0d724ed5ae70afce62187c492"},
+ {file = "cffi-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:dca802c8db0720ce1c49cce1149ff7b06e91ba15fa84b1d59144fef1a1bc7ac2"},
+ {file = "cffi-1.17.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6ce01337d23884b21c03869d2f68c5523d43174d4fc405490eb0091057943118"},
+ {file = "cffi-1.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cab2eba3830bf4f6d91e2d6718e0e1c14a2f5ad1af68a89d24ace0c6b17cced7"},
+ {file = "cffi-1.17.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:14b9cbc8f7ac98a739558eb86fabc283d4d564dafed50216e7f7ee62d0d25377"},
+ {file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b00e7bcd71caa0282cbe3c90966f738e2db91e64092a877c3ff7f19a1628fdcb"},
+ {file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41f4915e09218744d8bae14759f983e466ab69b178de38066f7579892ff2a555"},
+ {file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e4760a68cab57bfaa628938e9c2971137e05ce48e762a9cb53b76c9b569f1204"},
+ {file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:011aff3524d578a9412c8b3cfaa50f2c0bd78e03eb7af7aa5e0df59b158efb2f"},
+ {file = "cffi-1.17.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a003ac9edc22d99ae1286b0875c460351f4e101f8c9d9d2576e78d7e048f64e0"},
+ {file = "cffi-1.17.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ef9528915df81b8f4c7612b19b8628214c65c9b7f74db2e34a646a0a2a0da2d4"},
+ {file = "cffi-1.17.0-cp313-cp313-win32.whl", hash = "sha256:70d2aa9fb00cf52034feac4b913181a6e10356019b18ef89bc7c12a283bf5f5a"},
+ {file = "cffi-1.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:b7b6ea9e36d32582cda3465f54c4b454f62f23cb083ebc7a94e2ca6ef011c3a7"},
+ {file = "cffi-1.17.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:964823b2fc77b55355999ade496c54dde161c621cb1f6eac61dc30ed1b63cd4c"},
+ {file = "cffi-1.17.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:516a405f174fd3b88829eabfe4bb296ac602d6a0f68e0d64d5ac9456194a5b7e"},
+ {file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dec6b307ce928e8e112a6bb9921a1cb00a0e14979bf28b98e084a4b8a742bd9b"},
+ {file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e4094c7b464cf0a858e75cd14b03509e84789abf7b79f8537e6a72152109c76e"},
+ {file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2404f3de742f47cb62d023f0ba7c5a916c9c653d5b368cc966382ae4e57da401"},
+ {file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3aa9d43b02a0c681f0bfbc12d476d47b2b2b6a3f9287f11ee42989a268a1833c"},
+ {file = "cffi-1.17.0-cp38-cp38-win32.whl", hash = "sha256:0bb15e7acf8ab35ca8b24b90af52c8b391690ef5c4aec3d31f38f0d37d2cc499"},
+ {file = "cffi-1.17.0-cp38-cp38-win_amd64.whl", hash = "sha256:93a7350f6706b31f457c1457d3a3259ff9071a66f312ae64dc024f049055f72c"},
+ {file = "cffi-1.17.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1a2ddbac59dc3716bc79f27906c010406155031a1c801410f1bafff17ea304d2"},
+ {file = "cffi-1.17.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6327b572f5770293fc062a7ec04160e89741e8552bf1c358d1a23eba68166759"},
+ {file = "cffi-1.17.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dbc183e7bef690c9abe5ea67b7b60fdbca81aa8da43468287dae7b5c046107d4"},
+ {file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5bdc0f1f610d067c70aa3737ed06e2726fd9d6f7bfee4a351f4c40b6831f4e82"},
+ {file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6d872186c1617d143969defeadac5a904e6e374183e07977eedef9c07c8953bf"},
+ {file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0d46ee4764b88b91f16661a8befc6bfb24806d885e27436fdc292ed7e6f6d058"},
+ {file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f76a90c345796c01d85e6332e81cab6d70de83b829cf1d9762d0a3da59c7932"},
+ {file = "cffi-1.17.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0e60821d312f99d3e1569202518dddf10ae547e799d75aef3bca3a2d9e8ee693"},
+ {file = "cffi-1.17.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:eb09b82377233b902d4c3fbeeb7ad731cdab579c6c6fda1f763cd779139e47c3"},
+ {file = "cffi-1.17.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:24658baf6224d8f280e827f0a50c46ad819ec8ba380a42448e24459daf809cf4"},
+ {file = "cffi-1.17.0-cp39-cp39-win32.whl", hash = "sha256:0fdacad9e0d9fc23e519efd5ea24a70348305e8d7d85ecbb1a5fa66dc834e7fb"},
+ {file = "cffi-1.17.0-cp39-cp39-win_amd64.whl", hash = "sha256:7cbc78dc018596315d4e7841c8c3a7ae31cc4d638c9b627f87d52e8abaaf2d29"},
+ {file = "cffi-1.17.0.tar.gz", hash = "sha256:f3157624b7558b914cb039fd1af735e5e8049a87c817cc215109ad1c8779df76"},
]
[package.dependencies]
@@ -743,33 +759,33 @@ tests = ["pytest", "pytest-cov", "pytest-xdist"]
[[package]]
name = "debugpy"
-version = "1.8.2"
+version = "1.8.5"
description = "An implementation of the Debug Adapter Protocol for Python"
optional = false
python-versions = ">=3.8"
files = [
- {file = "debugpy-1.8.2-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:7ee2e1afbf44b138c005e4380097d92532e1001580853a7cb40ed84e0ef1c3d2"},
- {file = "debugpy-1.8.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f8c3f7c53130a070f0fc845a0f2cee8ed88d220d6b04595897b66605df1edd6"},
- {file = "debugpy-1.8.2-cp310-cp310-win32.whl", hash = "sha256:f179af1e1bd4c88b0b9f0fa153569b24f6b6f3de33f94703336363ae62f4bf47"},
- {file = "debugpy-1.8.2-cp310-cp310-win_amd64.whl", hash = "sha256:0600faef1d0b8d0e85c816b8bb0cb90ed94fc611f308d5fde28cb8b3d2ff0fe3"},
- {file = "debugpy-1.8.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:8a13417ccd5978a642e91fb79b871baded925d4fadd4dfafec1928196292aa0a"},
- {file = "debugpy-1.8.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acdf39855f65c48ac9667b2801234fc64d46778021efac2de7e50907ab90c634"},
- {file = "debugpy-1.8.2-cp311-cp311-win32.whl", hash = "sha256:2cbd4d9a2fc5e7f583ff9bf11f3b7d78dfda8401e8bb6856ad1ed190be4281ad"},
- {file = "debugpy-1.8.2-cp311-cp311-win_amd64.whl", hash = "sha256:d3408fddd76414034c02880e891ea434e9a9cf3a69842098ef92f6e809d09afa"},
- {file = "debugpy-1.8.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:5d3ccd39e4021f2eb86b8d748a96c766058b39443c1f18b2dc52c10ac2757835"},
- {file = "debugpy-1.8.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:62658aefe289598680193ff655ff3940e2a601765259b123dc7f89c0239b8cd3"},
- {file = "debugpy-1.8.2-cp312-cp312-win32.whl", hash = "sha256:bd11fe35d6fd3431f1546d94121322c0ac572e1bfb1f6be0e9b8655fb4ea941e"},
- {file = "debugpy-1.8.2-cp312-cp312-win_amd64.whl", hash = "sha256:15bc2f4b0f5e99bf86c162c91a74c0631dbd9cef3c6a1d1329c946586255e859"},
- {file = "debugpy-1.8.2-cp38-cp38-macosx_11_0_x86_64.whl", hash = "sha256:5a019d4574afedc6ead1daa22736c530712465c0c4cd44f820d803d937531b2d"},
- {file = "debugpy-1.8.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40f062d6877d2e45b112c0bbade9a17aac507445fd638922b1a5434df34aed02"},
- {file = "debugpy-1.8.2-cp38-cp38-win32.whl", hash = "sha256:c78ba1680f1015c0ca7115671fe347b28b446081dada3fedf54138f44e4ba031"},
- {file = "debugpy-1.8.2-cp38-cp38-win_amd64.whl", hash = "sha256:cf327316ae0c0e7dd81eb92d24ba8b5e88bb4d1b585b5c0d32929274a66a5210"},
- {file = "debugpy-1.8.2-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:1523bc551e28e15147815d1397afc150ac99dbd3a8e64641d53425dba57b0ff9"},
- {file = "debugpy-1.8.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e24ccb0cd6f8bfaec68d577cb49e9c680621c336f347479b3fce060ba7c09ec1"},
- {file = "debugpy-1.8.2-cp39-cp39-win32.whl", hash = "sha256:7f8d57a98c5a486c5c7824bc0b9f2f11189d08d73635c326abef268f83950326"},
- {file = "debugpy-1.8.2-cp39-cp39-win_amd64.whl", hash = "sha256:16c8dcab02617b75697a0a925a62943e26a0330da076e2a10437edd9f0bf3755"},
- {file = "debugpy-1.8.2-py2.py3-none-any.whl", hash = "sha256:16e16df3a98a35c63c3ab1e4d19be4cbc7fdda92d9ddc059294f18910928e0ca"},
- {file = "debugpy-1.8.2.zip", hash = "sha256:95378ed08ed2089221896b9b3a8d021e642c24edc8fef20e5d4342ca8be65c00"},
+ {file = "debugpy-1.8.5-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:7e4d594367d6407a120b76bdaa03886e9eb652c05ba7f87e37418426ad2079f7"},
+ {file = "debugpy-1.8.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4413b7a3ede757dc33a273a17d685ea2b0c09dbd312cc03f5534a0fd4d40750a"},
+ {file = "debugpy-1.8.5-cp310-cp310-win32.whl", hash = "sha256:dd3811bd63632bb25eda6bd73bea8e0521794cda02be41fa3160eb26fc29e7ed"},
+ {file = "debugpy-1.8.5-cp310-cp310-win_amd64.whl", hash = "sha256:b78c1250441ce893cb5035dd6f5fc12db968cc07f91cc06996b2087f7cefdd8e"},
+ {file = "debugpy-1.8.5-cp311-cp311-macosx_12_0_universal2.whl", hash = "sha256:606bccba19f7188b6ea9579c8a4f5a5364ecd0bf5a0659c8a5d0e10dcee3032a"},
+ {file = "debugpy-1.8.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db9fb642938a7a609a6c865c32ecd0d795d56c1aaa7a7a5722d77855d5e77f2b"},
+ {file = "debugpy-1.8.5-cp311-cp311-win32.whl", hash = "sha256:4fbb3b39ae1aa3e5ad578f37a48a7a303dad9a3d018d369bc9ec629c1cfa7408"},
+ {file = "debugpy-1.8.5-cp311-cp311-win_amd64.whl", hash = "sha256:345d6a0206e81eb68b1493ce2fbffd57c3088e2ce4b46592077a943d2b968ca3"},
+ {file = "debugpy-1.8.5-cp312-cp312-macosx_12_0_universal2.whl", hash = "sha256:5b5c770977c8ec6c40c60d6f58cacc7f7fe5a45960363d6974ddb9b62dbee156"},
+ {file = "debugpy-1.8.5-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0a65b00b7cdd2ee0c2cf4c7335fef31e15f1b7056c7fdbce9e90193e1a8c8cb"},
+ {file = "debugpy-1.8.5-cp312-cp312-win32.whl", hash = "sha256:c9f7c15ea1da18d2fcc2709e9f3d6de98b69a5b0fff1807fb80bc55f906691f7"},
+ {file = "debugpy-1.8.5-cp312-cp312-win_amd64.whl", hash = "sha256:28ced650c974aaf179231668a293ecd5c63c0a671ae6d56b8795ecc5d2f48d3c"},
+ {file = "debugpy-1.8.5-cp38-cp38-macosx_12_0_x86_64.whl", hash = "sha256:3df6692351172a42af7558daa5019651f898fc67450bf091335aa8a18fbf6f3a"},
+ {file = "debugpy-1.8.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1cd04a73eb2769eb0bfe43f5bfde1215c5923d6924b9b90f94d15f207a402226"},
+ {file = "debugpy-1.8.5-cp38-cp38-win32.whl", hash = "sha256:8f913ee8e9fcf9d38a751f56e6de12a297ae7832749d35de26d960f14280750a"},
+ {file = "debugpy-1.8.5-cp38-cp38-win_amd64.whl", hash = "sha256:a697beca97dad3780b89a7fb525d5e79f33821a8bc0c06faf1f1289e549743cf"},
+ {file = "debugpy-1.8.5-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:0a1029a2869d01cb777216af8c53cda0476875ef02a2b6ff8b2f2c9a4b04176c"},
+ {file = "debugpy-1.8.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e84c276489e141ed0b93b0af648eef891546143d6a48f610945416453a8ad406"},
+ {file = "debugpy-1.8.5-cp39-cp39-win32.whl", hash = "sha256:ad84b7cde7fd96cf6eea34ff6c4a1b7887e0fe2ea46e099e53234856f9d99a34"},
+ {file = "debugpy-1.8.5-cp39-cp39-win_amd64.whl", hash = "sha256:7b0fe36ed9d26cb6836b0a51453653f8f2e347ba7348f2bbfe76bfeb670bfb1c"},
+ {file = "debugpy-1.8.5-py2.py3-none-any.whl", hash = "sha256:55919dce65b471eff25901acf82d328bbd5b833526b6c1364bd5133754777a44"},
+ {file = "debugpy-1.8.5.zip", hash = "sha256:b2112cfeb34b4507399d298fe7023a16656fc553ed5246536060ca7bd0e668d0"},
]
[[package]]
@@ -1123,13 +1139,13 @@ six = "*"
[[package]]
name = "griffe"
-version = "0.47.0"
+version = "0.48.0"
description = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API."
optional = false
python-versions = ">=3.8"
files = [
- {file = "griffe-0.47.0-py3-none-any.whl", hash = "sha256:07a2fd6a8c3d21d0bbb0decf701d62042ccc8a576645c7f8799fe1f10de2b2de"},
- {file = "griffe-0.47.0.tar.gz", hash = "sha256:95119a440a3c932b13293538bdbc405bee4c36428547553dc6b327e7e7d35e5a"},
+ {file = "griffe-0.48.0-py3-none-any.whl", hash = "sha256:f944c6ff7bd31cf76f264adcd6ab8f3d00a2f972ae5cc8db2d7b6dcffeff65a2"},
+ {file = "griffe-0.48.0.tar.gz", hash = "sha256:f099461c02f016b6be4af386d5aa92b01fb4efe6c1c2c360dda9a5d0a863bb7f"},
]
[package.dependencies]
@@ -1137,61 +1153,61 @@ colorama = ">=0.4"
[[package]]
name = "grpcio"
-version = "1.64.1"
+version = "1.65.4"
description = "HTTP/2-based RPC framework"
optional = false
python-versions = ">=3.8"
files = [
- {file = "grpcio-1.64.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:55697ecec192bc3f2f3cc13a295ab670f51de29884ca9ae6cd6247df55df2502"},
- {file = "grpcio-1.64.1-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:3b64ae304c175671efdaa7ec9ae2cc36996b681eb63ca39c464958396697daff"},
- {file = "grpcio-1.64.1-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:bac71b4b28bc9af61efcdc7630b166440bbfbaa80940c9a697271b5e1dabbc61"},
- {file = "grpcio-1.64.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6c024ffc22d6dc59000faf8ad781696d81e8e38f4078cb0f2630b4a3cf231a90"},
- {file = "grpcio-1.64.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7cd5c1325f6808b8ae31657d281aadb2a51ac11ab081ae335f4f7fc44c1721d"},
- {file = "grpcio-1.64.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:0a2813093ddb27418a4c99f9b1c223fab0b053157176a64cc9db0f4557b69bd9"},
- {file = "grpcio-1.64.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2981c7365a9353f9b5c864595c510c983251b1ab403e05b1ccc70a3d9541a73b"},
- {file = "grpcio-1.64.1-cp310-cp310-win32.whl", hash = "sha256:1262402af5a511c245c3ae918167eca57342c72320dffae5d9b51840c4b2f86d"},
- {file = "grpcio-1.64.1-cp310-cp310-win_amd64.whl", hash = "sha256:19264fc964576ddb065368cae953f8d0514ecc6cb3da8903766d9fb9d4554c33"},
- {file = "grpcio-1.64.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:58b1041e7c870bb30ee41d3090cbd6f0851f30ae4eb68228955d973d3efa2e61"},
- {file = "grpcio-1.64.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:bbc5b1d78a7822b0a84c6f8917faa986c1a744e65d762ef6d8be9d75677af2ca"},
- {file = "grpcio-1.64.1-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:5841dd1f284bd1b3d8a6eca3a7f062b06f1eec09b184397e1d1d43447e89a7ae"},
- {file = "grpcio-1.64.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8caee47e970b92b3dd948371230fcceb80d3f2277b3bf7fbd7c0564e7d39068e"},
- {file = "grpcio-1.64.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73819689c169417a4f978e562d24f2def2be75739c4bed1992435d007819da1b"},
- {file = "grpcio-1.64.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:6503b64c8b2dfad299749cad1b595c650c91e5b2c8a1b775380fcf8d2cbba1e9"},
- {file = "grpcio-1.64.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1de403fc1305fd96cfa75e83be3dee8538f2413a6b1685b8452301c7ba33c294"},
- {file = "grpcio-1.64.1-cp311-cp311-win32.whl", hash = "sha256:d4d29cc612e1332237877dfa7fe687157973aab1d63bd0f84cf06692f04c0367"},
- {file = "grpcio-1.64.1-cp311-cp311-win_amd64.whl", hash = "sha256:5e56462b05a6f860b72f0fa50dca06d5b26543a4e88d0396259a07dc30f4e5aa"},
- {file = "grpcio-1.64.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:4657d24c8063e6095f850b68f2d1ba3b39f2b287a38242dcabc166453e950c59"},
- {file = "grpcio-1.64.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:62b4e6eb7bf901719fce0ca83e3ed474ae5022bb3827b0a501e056458c51c0a1"},
- {file = "grpcio-1.64.1-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:ee73a2f5ca4ba44fa33b4d7d2c71e2c8a9e9f78d53f6507ad68e7d2ad5f64a22"},
- {file = "grpcio-1.64.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:198908f9b22e2672a998870355e226a725aeab327ac4e6ff3a1399792ece4762"},
- {file = "grpcio-1.64.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39b9d0acaa8d835a6566c640f48b50054f422d03e77e49716d4c4e8e279665a1"},
- {file = "grpcio-1.64.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:5e42634a989c3aa6049f132266faf6b949ec2a6f7d302dbb5c15395b77d757eb"},
- {file = "grpcio-1.64.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:b1a82e0b9b3022799c336e1fc0f6210adc019ae84efb7321d668129d28ee1efb"},
- {file = "grpcio-1.64.1-cp312-cp312-win32.whl", hash = "sha256:55260032b95c49bee69a423c2f5365baa9369d2f7d233e933564d8a47b893027"},
- {file = "grpcio-1.64.1-cp312-cp312-win_amd64.whl", hash = "sha256:c1a786ac592b47573a5bb7e35665c08064a5d77ab88a076eec11f8ae86b3e3f6"},
- {file = "grpcio-1.64.1-cp38-cp38-linux_armv7l.whl", hash = "sha256:a011ac6c03cfe162ff2b727bcb530567826cec85eb8d4ad2bfb4bd023287a52d"},
- {file = "grpcio-1.64.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:4d6dab6124225496010bd22690f2d9bd35c7cbb267b3f14e7a3eb05c911325d4"},
- {file = "grpcio-1.64.1-cp38-cp38-manylinux_2_17_aarch64.whl", hash = "sha256:a5e771d0252e871ce194d0fdcafd13971f1aae0ddacc5f25615030d5df55c3a2"},
- {file = "grpcio-1.64.1-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2c3c1b90ab93fed424e454e93c0ed0b9d552bdf1b0929712b094f5ecfe7a23ad"},
- {file = "grpcio-1.64.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:20405cb8b13fd779135df23fabadc53b86522d0f1cba8cca0e87968587f50650"},
- {file = "grpcio-1.64.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:0cc79c982ccb2feec8aad0e8fb0d168bcbca85bc77b080d0d3c5f2f15c24ea8f"},
- {file = "grpcio-1.64.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:a3a035c37ce7565b8f4f35ff683a4db34d24e53dc487e47438e434eb3f701b2a"},
- {file = "grpcio-1.64.1-cp38-cp38-win32.whl", hash = "sha256:1257b76748612aca0f89beec7fa0615727fd6f2a1ad580a9638816a4b2eb18fd"},
- {file = "grpcio-1.64.1-cp38-cp38-win_amd64.whl", hash = "sha256:0a12ddb1678ebc6a84ec6b0487feac020ee2b1659cbe69b80f06dbffdb249122"},
- {file = "grpcio-1.64.1-cp39-cp39-linux_armv7l.whl", hash = "sha256:75dbbf415026d2862192fe1b28d71f209e2fd87079d98470db90bebe57b33179"},
- {file = "grpcio-1.64.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e3d9f8d1221baa0ced7ec7322a981e28deb23749c76eeeb3d33e18b72935ab62"},
- {file = "grpcio-1.64.1-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:5f8b75f64d5d324c565b263c67dbe4f0af595635bbdd93bb1a88189fc62ed2e5"},
- {file = "grpcio-1.64.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c84ad903d0d94311a2b7eea608da163dace97c5fe9412ea311e72c3684925602"},
- {file = "grpcio-1.64.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:940e3ec884520155f68a3b712d045e077d61c520a195d1a5932c531f11883489"},
- {file = "grpcio-1.64.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f10193c69fc9d3d726e83bbf0f3d316f1847c3071c8c93d8090cf5f326b14309"},
- {file = "grpcio-1.64.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ac15b6c2c80a4d1338b04d42a02d376a53395ddf0ec9ab157cbaf44191f3ffdd"},
- {file = "grpcio-1.64.1-cp39-cp39-win32.whl", hash = "sha256:03b43d0ccf99c557ec671c7dede64f023c7da9bb632ac65dbc57f166e4970040"},
- {file = "grpcio-1.64.1-cp39-cp39-win_amd64.whl", hash = "sha256:ed6091fa0adcc7e4ff944090cf203a52da35c37a130efa564ded02b7aff63bcd"},
- {file = "grpcio-1.64.1.tar.gz", hash = "sha256:8d51dd1c59d5fa0f34266b80a3805ec29a1f26425c2a54736133f6d87fc4968a"},
+ {file = "grpcio-1.65.4-cp310-cp310-linux_armv7l.whl", hash = "sha256:0e85c8766cf7f004ab01aff6a0393935a30d84388fa3c58d77849fcf27f3e98c"},
+ {file = "grpcio-1.65.4-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:e4a795c02405c7dfa8affd98c14d980f4acea16ea3b539e7404c645329460e5a"},
+ {file = "grpcio-1.65.4-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:d7b984a8dd975d949c2042b9b5ebcf297d6d5af57dcd47f946849ee15d3c2fb8"},
+ {file = "grpcio-1.65.4-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:644a783ce604a7d7c91412bd51cf9418b942cf71896344b6dc8d55713c71ce82"},
+ {file = "grpcio-1.65.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5764237d751d3031a36fafd57eb7d36fd2c10c658d2b4057c516ccf114849a3e"},
+ {file = "grpcio-1.65.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:ee40d058cf20e1dd4cacec9c39e9bce13fedd38ce32f9ba00f639464fcb757de"},
+ {file = "grpcio-1.65.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4482a44ce7cf577a1f8082e807a5b909236bce35b3e3897f839f2fbd9ae6982d"},
+ {file = "grpcio-1.65.4-cp310-cp310-win32.whl", hash = "sha256:66bb051881c84aa82e4f22d8ebc9d1704b2e35d7867757f0740c6ef7b902f9b1"},
+ {file = "grpcio-1.65.4-cp310-cp310-win_amd64.whl", hash = "sha256:870370524eff3144304da4d1bbe901d39bdd24f858ce849b7197e530c8c8f2ec"},
+ {file = "grpcio-1.65.4-cp311-cp311-linux_armv7l.whl", hash = "sha256:85e9c69378af02e483bc626fc19a218451b24a402bdf44c7531e4c9253fb49ef"},
+ {file = "grpcio-1.65.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2bd672e005afab8bf0d6aad5ad659e72a06dd713020554182a66d7c0c8f47e18"},
+ {file = "grpcio-1.65.4-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:abccc5d73f5988e8f512eb29341ed9ced923b586bb72e785f265131c160231d8"},
+ {file = "grpcio-1.65.4-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:886b45b29f3793b0c2576201947258782d7e54a218fe15d4a0468d9a6e00ce17"},
+ {file = "grpcio-1.65.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be952436571dacc93ccc7796db06b7daf37b3b56bb97e3420e6503dccfe2f1b4"},
+ {file = "grpcio-1.65.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:8dc9ddc4603ec43f6238a5c95400c9a901b6d079feb824e890623da7194ff11e"},
+ {file = "grpcio-1.65.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ade1256c98cba5a333ef54636095f2c09e6882c35f76acb04412f3b1aa3c29a5"},
+ {file = "grpcio-1.65.4-cp311-cp311-win32.whl", hash = "sha256:280e93356fba6058cbbfc6f91a18e958062ef1bdaf5b1caf46c615ba1ae71b5b"},
+ {file = "grpcio-1.65.4-cp311-cp311-win_amd64.whl", hash = "sha256:d2b819f9ee27ed4e3e737a4f3920e337e00bc53f9e254377dd26fc7027c4d558"},
+ {file = "grpcio-1.65.4-cp312-cp312-linux_armv7l.whl", hash = "sha256:926a0750a5e6fb002542e80f7fa6cab8b1a2ce5513a1c24641da33e088ca4c56"},
+ {file = "grpcio-1.65.4-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:2a1d4c84d9e657f72bfbab8bedf31bdfc6bfc4a1efb10b8f2d28241efabfaaf2"},
+ {file = "grpcio-1.65.4-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:17de4fda50967679677712eec0a5c13e8904b76ec90ac845d83386b65da0ae1e"},
+ {file = "grpcio-1.65.4-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3dee50c1b69754a4228e933696408ea87f7e896e8d9797a3ed2aeed8dbd04b74"},
+ {file = "grpcio-1.65.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:74c34fc7562bdd169b77966068434a93040bfca990e235f7a67cdf26e1bd5c63"},
+ {file = "grpcio-1.65.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:24a2246e80a059b9eb981e4c2a6d8111b1b5e03a44421adbf2736cc1d4988a8a"},
+ {file = "grpcio-1.65.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18c10f0d054d2dce34dd15855fcca7cc44ec3b811139437543226776730c0f28"},
+ {file = "grpcio-1.65.4-cp312-cp312-win32.whl", hash = "sha256:d72962788b6c22ddbcdb70b10c11fbb37d60ae598c51eb47ec019db66ccfdff0"},
+ {file = "grpcio-1.65.4-cp312-cp312-win_amd64.whl", hash = "sha256:7656376821fed8c89e68206a522522317787a3d9ed66fb5110b1dff736a5e416"},
+ {file = "grpcio-1.65.4-cp38-cp38-linux_armv7l.whl", hash = "sha256:4934077b33aa6fe0b451de8b71dabde96bf2d9b4cb2b3187be86e5adebcba021"},
+ {file = "grpcio-1.65.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0cef8c919a3359847c357cb4314e50ed1f0cca070f828ee8f878d362fd744d52"},
+ {file = "grpcio-1.65.4-cp38-cp38-manylinux_2_17_aarch64.whl", hash = "sha256:a925446e6aa12ca37114840d8550f308e29026cdc423a73da3043fd1603a6385"},
+ {file = "grpcio-1.65.4-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf53e6247f1e2af93657e62e240e4f12e11ee0b9cef4ddcb37eab03d501ca864"},
+ {file = "grpcio-1.65.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdb34278e4ceb224c89704cd23db0d902e5e3c1c9687ec9d7c5bb4c150f86816"},
+ {file = "grpcio-1.65.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:e6cbdd107e56bde55c565da5fd16f08e1b4e9b0674851d7749e7f32d8645f524"},
+ {file = "grpcio-1.65.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:626319a156b1f19513156a3b0dbfe977f5f93db63ca673a0703238ebd40670d7"},
+ {file = "grpcio-1.65.4-cp38-cp38-win32.whl", hash = "sha256:3d1bbf7e1dd1096378bd83c83f554d3b93819b91161deaf63e03b7022a85224a"},
+ {file = "grpcio-1.65.4-cp38-cp38-win_amd64.whl", hash = "sha256:a99e6dffefd3027b438116f33ed1261c8d360f0dd4f943cb44541a2782eba72f"},
+ {file = "grpcio-1.65.4-cp39-cp39-linux_armv7l.whl", hash = "sha256:874acd010e60a2ec1e30d5e505b0651ab12eb968157cd244f852b27c6dbed733"},
+ {file = "grpcio-1.65.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b07f36faf01fca5427d4aa23645e2d492157d56c91fab7e06fe5697d7e171ad4"},
+ {file = "grpcio-1.65.4-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:b81711bf4ec08a3710b534e8054c7dcf90f2edc22bebe11c1775a23f145595fe"},
+ {file = "grpcio-1.65.4-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88fcabc332a4aef8bcefadc34a02e9ab9407ab975d2c7d981a8e12c1aed92aa1"},
+ {file = "grpcio-1.65.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9ba3e63108a8749994f02c7c0e156afb39ba5bdf755337de8e75eb685be244b"},
+ {file = "grpcio-1.65.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:8eb485801957a486bf5de15f2c792d9f9c897a86f2f18db8f3f6795a094b4bb2"},
+ {file = "grpcio-1.65.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:075f3903bc1749ace93f2b0664f72964ee5f2da5c15d4b47e0ab68e4f442c257"},
+ {file = "grpcio-1.65.4-cp39-cp39-win32.whl", hash = "sha256:0a0720299bdb2cc7306737295d56e41ce8827d5669d4a3cd870af832e3b17c4d"},
+ {file = "grpcio-1.65.4-cp39-cp39-win_amd64.whl", hash = "sha256:a146bc40fa78769f22e1e9ff4f110ef36ad271b79707577bf2a31e3e931141b9"},
+ {file = "grpcio-1.65.4.tar.gz", hash = "sha256:2a4f476209acffec056360d3e647ae0e14ae13dcf3dfb130c227ae1c594cbe39"},
]
[package.extras]
-protobuf = ["grpcio-tools (>=1.64.1)"]
+protobuf = ["grpcio-tools (>=1.65.4)"]
[[package]]
name = "gviz-api"
@@ -1677,13 +1693,13 @@ test = ["jupyter-server (>=2.0.0)", "pytest (>=7.0)", "pytest-jupyter[server] (>
[[package]]
name = "jupyterlab"
-version = "4.2.3"
+version = "4.2.4"
description = "JupyterLab computational environment"
optional = false
python-versions = ">=3.8"
files = [
- {file = "jupyterlab-4.2.3-py3-none-any.whl", hash = "sha256:0b59d11808e84bb84105c73364edfa867dd475492429ab34ea388a52f2e2e596"},
- {file = "jupyterlab-4.2.3.tar.gz", hash = "sha256:df6e46969ea51d66815167f23d92f105423b7f1f06fa604d4f44aeb018c82c7b"},
+ {file = "jupyterlab-4.2.4-py3-none-any.whl", hash = "sha256:807a7ec73637744f879e112060d4b9d9ebe028033b7a429b2d1f4fc523d00245"},
+ {file = "jupyterlab-4.2.4.tar.gz", hash = "sha256:343a979fb9582fd08c8511823e320703281cd072a0049bcdafdc7afeda7f2537"},
]
[package.dependencies]
@@ -1706,7 +1722,7 @@ dev = ["build", "bump2version", "coverage", "hatch", "pre-commit", "pytest-cov",
docs = ["jsx-lexer", "myst-parser", "pydata-sphinx-theme (>=0.13.0)", "pytest", "pytest-check-links", "pytest-jupyter", "sphinx (>=1.8,<7.3.0)", "sphinx-copybutton"]
docs-screenshots = ["altair (==5.3.0)", "ipython (==8.16.1)", "ipywidgets (==8.1.2)", "jupyterlab-geojson (==3.4.0)", "jupyterlab-language-pack-zh-cn (==4.1.post2)", "matplotlib (==3.8.3)", "nbconvert (>=7.0.0)", "pandas (==2.2.1)", "scipy (==1.12.0)", "vega-datasets (==0.9.0)"]
test = ["coverage", "pytest (>=7.0)", "pytest-check-links (>=0.7)", "pytest-console-scripts", "pytest-cov", "pytest-jupyter (>=0.5.3)", "pytest-timeout", "pytest-tornasync", "requests", "requests-cache", "virtualenv"]
-upgrade-extension = ["copier (>=8,<10)", "jinja2-time (<0.3)", "pydantic (<2.0)", "pyyaml-include (<2.0)", "tomli-w (<2.0)"]
+upgrade-extension = ["copier (>=9,<10)", "jinja2-time (<0.3)", "pydantic (<3.0)", "pyyaml-include (<3.0)", "tomli-w (<2.0)"]
[[package]]
name = "jupyterlab-pygments"
@@ -1721,13 +1737,13 @@ files = [
[[package]]
name = "jupyterlab-server"
-version = "2.27.2"
+version = "2.27.3"
description = "A set of server components for JupyterLab and JupyterLab like applications."
optional = false
python-versions = ">=3.8"
files = [
- {file = "jupyterlab_server-2.27.2-py3-none-any.whl", hash = "sha256:54aa2d64fd86383b5438d9f0c032f043c4d8c0264b8af9f60bd061157466ea43"},
- {file = "jupyterlab_server-2.27.2.tar.gz", hash = "sha256:15cbb349dc45e954e09bacf81b9f9bcb10815ff660fb2034ecd7417db3a7ea27"},
+ {file = "jupyterlab_server-2.27.3-py3-none-any.whl", hash = "sha256:e697488f66c3db49df675158a77b3b017520d772c6e1548c7d9bcc5df7944ee4"},
+ {file = "jupyterlab_server-2.27.3.tar.gz", hash = "sha256:eb36caca59e74471988f0ae25c77945610b887f777255aa21f8065def9e51ed4"},
]
[package.dependencies]
@@ -1746,13 +1762,13 @@ test = ["hatch", "ipykernel", "openapi-core (>=0.18.0,<0.19.0)", "openapi-spec-v
[[package]]
name = "jupytext"
-version = "1.16.3"
+version = "1.16.4"
description = "Jupyter notebooks as Markdown documents, Julia, Python or R scripts"
optional = false
python-versions = ">=3.8"
files = [
- {file = "jupytext-1.16.3-py3-none-any.whl", hash = "sha256:870e0d7a716dcb1303df6ad1cec65e3315a20daedd808a55cb3dae2d56e4ed20"},
- {file = "jupytext-1.16.3.tar.gz", hash = "sha256:1ebac990461dd9f477ff7feec9e3003fa1acc89f3c16ba01b73f79fd76f01a98"},
+ {file = "jupytext-1.16.4-py3-none-any.whl", hash = "sha256:76989d2690e65667ea6fb411d8056abe7cd0437c07bd774660b83d62acf9490a"},
+ {file = "jupytext-1.16.4.tar.gz", hash = "sha256:28e33f46f2ce7a41fb9d677a4a2c95327285579b64ca104437c4b9eb1e4174e9"},
]
[package.dependencies]
@@ -2052,40 +2068,40 @@ files = [
[[package]]
name = "matplotlib"
-version = "3.9.1"
+version = "3.9.1.post1"
description = "Python plotting package"
optional = false
python-versions = ">=3.9"
files = [
- {file = "matplotlib-3.9.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:7ccd6270066feb9a9d8e0705aa027f1ff39f354c72a87efe8fa07632f30fc6bb"},
- {file = "matplotlib-3.9.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:591d3a88903a30a6d23b040c1e44d1afdd0d778758d07110eb7596f811f31842"},
- {file = "matplotlib-3.9.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd2a59ff4b83d33bca3b5ec58203cc65985367812cb8c257f3e101632be86d92"},
- {file = "matplotlib-3.9.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc001516ffcf1a221beb51198b194d9230199d6842c540108e4ce109ac05cc0"},
- {file = "matplotlib-3.9.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:83c6a792f1465d174c86d06f3ae85a8fe36e6f5964633ae8106312ec0921fdf5"},
- {file = "matplotlib-3.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:421851f4f57350bcf0811edd754a708d2275533e84f52f6760b740766c6747a7"},
- {file = "matplotlib-3.9.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:b3fce58971b465e01b5c538f9d44915640c20ec5ff31346e963c9e1cd66fa812"},
- {file = "matplotlib-3.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a973c53ad0668c53e0ed76b27d2eeeae8799836fd0d0caaa4ecc66bf4e6676c0"},
- {file = "matplotlib-3.9.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82cd5acf8f3ef43f7532c2f230249720f5dc5dd40ecafaf1c60ac8200d46d7eb"},
- {file = "matplotlib-3.9.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab38a4f3772523179b2f772103d8030215b318fef6360cb40558f585bf3d017f"},
- {file = "matplotlib-3.9.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2315837485ca6188a4b632c5199900e28d33b481eb083663f6a44cfc8987ded3"},
- {file = "matplotlib-3.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:a0c977c5c382f6696caf0bd277ef4f936da7e2aa202ff66cad5f0ac1428ee15b"},
- {file = "matplotlib-3.9.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:565d572efea2b94f264dd86ef27919515aa6d629252a169b42ce5f570db7f37b"},
- {file = "matplotlib-3.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6d397fd8ccc64af2ec0af1f0efc3bacd745ebfb9d507f3f552e8adb689ed730a"},
- {file = "matplotlib-3.9.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26040c8f5121cd1ad712abffcd4b5222a8aec3a0fe40bc8542c94331deb8780d"},
- {file = "matplotlib-3.9.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d12cb1837cffaac087ad6b44399d5e22b78c729de3cdae4629e252067b705e2b"},
- {file = "matplotlib-3.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0e835c6988edc3d2d08794f73c323cc62483e13df0194719ecb0723b564e0b5c"},
- {file = "matplotlib-3.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:44a21d922f78ce40435cb35b43dd7d573cf2a30138d5c4b709d19f00e3907fd7"},
- {file = "matplotlib-3.9.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:0c584210c755ae921283d21d01f03a49ef46d1afa184134dd0f95b0202ee6f03"},
- {file = "matplotlib-3.9.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:11fed08f34fa682c2b792942f8902e7aefeed400da71f9e5816bea40a7ce28fe"},
- {file = "matplotlib-3.9.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0000354e32efcfd86bda75729716b92f5c2edd5b947200be9881f0a671565c33"},
- {file = "matplotlib-3.9.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4db17fea0ae3aceb8e9ac69c7e3051bae0b3d083bfec932240f9bf5d0197a049"},
- {file = "matplotlib-3.9.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:208cbce658b72bf6a8e675058fbbf59f67814057ae78165d8a2f87c45b48d0ff"},
- {file = "matplotlib-3.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:dc23f48ab630474264276be156d0d7710ac6c5a09648ccdf49fef9200d8cbe80"},
- {file = "matplotlib-3.9.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3fda72d4d472e2ccd1be0e9ccb6bf0d2eaf635e7f8f51d737ed7e465ac020cb3"},
- {file = "matplotlib-3.9.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:84b3ba8429935a444f1fdc80ed930babbe06725bcf09fbeb5c8757a2cd74af04"},
- {file = "matplotlib-3.9.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b918770bf3e07845408716e5bbda17eadfc3fcbd9307dc67f37d6cf834bb3d98"},
- {file = "matplotlib-3.9.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:f1f2e5d29e9435c97ad4c36fb6668e89aee13d48c75893e25cef064675038ac9"},
- {file = "matplotlib-3.9.1.tar.gz", hash = "sha256:de06b19b8db95dd33d0dc17c926c7c9ebed9f572074b6fac4f65068a6814d010"},
+ {file = "matplotlib-3.9.1.post1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3779ad3e8b72df22b8a622c5796bbcfabfa0069b835412e3c1dec8ee3de92d0c"},
+ {file = "matplotlib-3.9.1.post1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ec400340f8628e8e2260d679078d4e9b478699f386e5cc8094e80a1cb0039c7c"},
+ {file = "matplotlib-3.9.1.post1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82c18791b8862ea095081f745b81f896b011c5a5091678fb33204fef641476af"},
+ {file = "matplotlib-3.9.1.post1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:621a628389c09a6b9f609a238af8e66acecece1cfa12febc5fe4195114ba7446"},
+ {file = "matplotlib-3.9.1.post1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9a54734ca761ebb27cd4f0b6c2ede696ab6861052d7d7e7b8f7a6782665115f5"},
+ {file = "matplotlib-3.9.1.post1-cp310-cp310-win_amd64.whl", hash = "sha256:0721f93db92311bb514e446842e2b21c004541dcca0281afa495053e017c5458"},
+ {file = "matplotlib-3.9.1.post1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:b08b46058fe2a31ecb81ef6aa3611f41d871f6a8280e9057cb4016cb3d8e894a"},
+ {file = "matplotlib-3.9.1.post1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:22b344e84fcc574f561b5731f89a7625db8ef80cdbb0026a8ea855a33e3429d1"},
+ {file = "matplotlib-3.9.1.post1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b49fee26d64aefa9f061b575f0f7b5fc4663e51f87375c7239efa3d30d908fa"},
+ {file = "matplotlib-3.9.1.post1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89eb7e89e2b57856533c5c98f018aa3254fa3789fcd86d5f80077b9034a54c9a"},
+ {file = "matplotlib-3.9.1.post1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c06e742bade41fda6176d4c9c78c9ea016e176cd338e62a1686384cb1eb8de41"},
+ {file = "matplotlib-3.9.1.post1-cp311-cp311-win_amd64.whl", hash = "sha256:c44edab5b849e0fc1f1c9d6e13eaa35ef65925f7be45be891d9784709ad95561"},
+ {file = "matplotlib-3.9.1.post1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bf28b09986aee06393e808e661c3466be9c21eff443c9bc881bce04bfbb0c500"},
+ {file = "matplotlib-3.9.1.post1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:92aeb8c439d4831510d8b9d5e39f31c16c7f37873879767c26b147cef61e54cd"},
+ {file = "matplotlib-3.9.1.post1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f15798b0691b45c80d3320358a88ce5a9d6f518b28575b3ea3ed31b4bd95d009"},
+ {file = "matplotlib-3.9.1.post1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d59fc6096da7b9c1df275f9afc3fef5cbf634c21df9e5f844cba3dd8deb1847d"},
+ {file = "matplotlib-3.9.1.post1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ab986817a32a70ce22302438691e7df4c6ee4a844d47289db9d583d873491e0b"},
+ {file = "matplotlib-3.9.1.post1-cp312-cp312-win_amd64.whl", hash = "sha256:0d78e7d2d86c4472da105d39aba9b754ed3dfeaeaa4ac7206b82706e0a5362fa"},
+ {file = "matplotlib-3.9.1.post1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:bd07eba6431b4dc9253cce6374a28c415e1d3a7dc9f8aba028ea7592f06fe172"},
+ {file = "matplotlib-3.9.1.post1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ca230cc4482010d646827bd2c6d140c98c361e769ae7d954ebf6fff2a226f5b1"},
+ {file = "matplotlib-3.9.1.post1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ace27c0fdeded399cbc43f22ffa76e0f0752358f5b33106ec7197534df08725a"},
+ {file = "matplotlib-3.9.1.post1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a4f3aeb7ba14c497dc6f021a076c48c2e5fbdf3da1e7264a5d649683e284a2f"},
+ {file = "matplotlib-3.9.1.post1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:23f96fbd4ff4cfa9b8a6b685a65e7eb3c2ced724a8d965995ec5c9c2b1f7daf5"},
+ {file = "matplotlib-3.9.1.post1-cp39-cp39-win_amd64.whl", hash = "sha256:2808b95452b4ffa14bfb7c7edffc5350743c31bda495f0d63d10fdd9bc69e895"},
+ {file = "matplotlib-3.9.1.post1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ffc91239f73b4179dec256b01299d46d0ffa9d27d98494bc1476a651b7821cbe"},
+ {file = "matplotlib-3.9.1.post1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:f965ebca9fd4feaaca45937c4849d92b70653057497181100fcd1e18161e5f29"},
+ {file = "matplotlib-3.9.1.post1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:801ee9323fd7b2da0d405aebbf98d1da77ea430bbbbbec6834c0b3af15e5db44"},
+ {file = "matplotlib-3.9.1.post1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:50113e9b43ceb285739f35d43db36aa752fb8154325b35d134ff6e177452f9ec"},
+ {file = "matplotlib-3.9.1.post1.tar.gz", hash = "sha256:c91e585c65092c975a44dc9d4239ba8c594ba3c193d7c478b6d178c4ef61f406"},
]
[package.dependencies]
@@ -2264,13 +2280,13 @@ pygments = ">2.12.0"
[[package]]
name = "mkdocs-material"
-version = "9.5.28"
+version = "9.5.31"
description = "Documentation that simply works"
optional = false
python-versions = ">=3.8"
files = [
- {file = "mkdocs_material-9.5.28-py3-none-any.whl", hash = "sha256:ff48b11b2a9f705dd210409ec3b418ab443dd36d96915bcba45a41f10ea27bfd"},
- {file = "mkdocs_material-9.5.28.tar.gz", hash = "sha256:9cba305283ad1600e3d0a67abe72d7a058b54793b47be39930911a588fe0336b"},
+ {file = "mkdocs_material-9.5.31-py3-none-any.whl", hash = "sha256:1b1f49066fdb3824c1e96d6bacd2d4375de4ac74580b47e79ff44c4d835c5fcb"},
+ {file = "mkdocs_material-9.5.31.tar.gz", hash = "sha256:31833ec664772669f5856f4f276bf3fdf0e642a445e64491eda459249c3a1ca8"},
]
[package.dependencies]
@@ -2304,13 +2320,13 @@ files = [
[[package]]
name = "mkdocstrings"
-version = "0.25.1"
+version = "0.25.2"
description = "Automatic documentation from sources, for MkDocs."
optional = false
python-versions = ">=3.8"
files = [
- {file = "mkdocstrings-0.25.1-py3-none-any.whl", hash = "sha256:da01fcc2670ad61888e8fe5b60afe9fee5781017d67431996832d63e887c2e51"},
- {file = "mkdocstrings-0.25.1.tar.gz", hash = "sha256:c3a2515f31577f311a9ee58d089e4c51fc6046dbd9e9b4c3de4c3194667fe9bf"},
+ {file = "mkdocstrings-0.25.2-py3-none-any.whl", hash = "sha256:9e2cda5e2e12db8bb98d21e3410f3f27f8faab685a24b03b06ba7daa5b92abfc"},
+ {file = "mkdocstrings-0.25.2.tar.gz", hash = "sha256:5cf57ad7f61e8be3111a2458b4e49c2029c9cb35525393b179f9c916ca8042dc"},
]
[package.dependencies]
@@ -2330,17 +2346,17 @@ python-legacy = ["mkdocstrings-python-legacy (>=0.2.1)"]
[[package]]
name = "mkdocstrings-python"
-version = "1.10.5"
+version = "1.10.7"
description = "A Python handler for mkdocstrings."
optional = false
python-versions = ">=3.8"
files = [
- {file = "mkdocstrings_python-1.10.5-py3-none-any.whl", hash = "sha256:92e3c588ef1b41151f55281d075de7558dd8092e422cb07a65b18ee2b0863ebb"},
- {file = "mkdocstrings_python-1.10.5.tar.gz", hash = "sha256:acdc2a98cd9d46c7ece508193a16ca03ccabcb67520352b7449f84b57c162bdf"},
+ {file = "mkdocstrings_python-1.10.7-py3-none-any.whl", hash = "sha256:8999acb8e2cb6ae5edb844ce1ed6a5fcc14285f85cfd9df374d9a0f0be8a40b6"},
+ {file = "mkdocstrings_python-1.10.7.tar.gz", hash = "sha256:bfb5e29acfc69c9177d2b11c18d3127d16e553b8da9bb6d184e428d54795600b"},
]
[package.dependencies]
-griffe = ">=0.47"
+griffe = ">=0.48"
mkdocstrings = ">=0.25"
[[package]]
@@ -2496,24 +2512,29 @@ name = "neuralspot-edge"
version = "0.1.3"
description = ""
optional = false
-python-versions = "<3.13,>=3.11"
-files = [
- {file = "neuralspot_edge-0.1.3-py3-none-any.whl", hash = "sha256:c346da7d3d2e6dca9bd221a4c3ddcef1ca0aa4a8adb21995284cf394b5a110bb"},
- {file = "neuralspot_edge-0.1.3.tar.gz", hash = "sha256:4fc082a0b956fd604616a4933dd46f84e04161a8260eaf014ddb6ccb4db6bedd"},
-]
+python-versions = ">=3.11,<3.13"
+files = []
+develop = false
[package.dependencies]
-h5py = ">=3.10.0,<4.0.0"
-keras = ">=3.0.4,<4.0.0"
-matplotlib = ">=3.9.0,<4.0.0"
-pandas = ">=2.2.2,<3.0.0"
-plotly = ">=5.22.0,<6.0.0"
-pydantic = ">=2.6.1,<3.0.0"
-requests = ">=2.31.0,<3.0.0"
-scikit-learn = ">=1.5.1,<2.0.0"
-seaborn = ">=0.13.2,<0.14.0"
-tensorflow = ">=2.16.1,<3.0.0"
-tqdm = ">=4.66.4,<5.0.0"
+boto3 = "^1.34.151"
+h5py = "^3.10.0"
+keras = "^3.0.4"
+matplotlib = "^3.9.0"
+pandas = "^2.2.2"
+plotly = "^5.22.0"
+pydantic = "^2.6.1"
+requests = "^2.31.0"
+scikit-learn = "^1.5.1"
+seaborn = "^0.13.2"
+tensorflow = "^2.16.1"
+tqdm = "^4.66.4"
+
+[package.source]
+type = "git"
+url = "https://github.com/AmbiqAI/neuralspot-edge.git"
+reference = "HEAD"
+resolved_reference = "1dfc7f7343b4a084d3912fc9c1daee33d64330b9"
[[package]]
name = "nodeenv"
@@ -2720,62 +2741,68 @@ torch = ["torch"]
[[package]]
name = "orjson"
-version = "3.10.6"
+version = "3.10.7"
description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy"
optional = false
python-versions = ">=3.8"
files = [
- {file = "orjson-3.10.6-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:fb0ee33124db6eaa517d00890fc1a55c3bfe1cf78ba4a8899d71a06f2d6ff5c7"},
- {file = "orjson-3.10.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c1c4b53b24a4c06547ce43e5fee6ec4e0d8fe2d597f4647fc033fd205707365"},
- {file = "orjson-3.10.6-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eadc8fd310edb4bdbd333374f2c8fec6794bbbae99b592f448d8214a5e4050c0"},
- {file = "orjson-3.10.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:61272a5aec2b2661f4fa2b37c907ce9701e821b2c1285d5c3ab0207ebd358d38"},
- {file = "orjson-3.10.6-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57985ee7e91d6214c837936dc1608f40f330a6b88bb13f5a57ce5257807da143"},
- {file = "orjson-3.10.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:633a3b31d9d7c9f02d49c4ab4d0a86065c4a6f6adc297d63d272e043472acab5"},
- {file = "orjson-3.10.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1c680b269d33ec444afe2bdc647c9eb73166fa47a16d9a75ee56a374f4a45f43"},
- {file = "orjson-3.10.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f759503a97a6ace19e55461395ab0d618b5a117e8d0fbb20e70cfd68a47327f2"},
- {file = "orjson-3.10.6-cp310-none-win32.whl", hash = "sha256:95a0cce17f969fb5391762e5719575217bd10ac5a189d1979442ee54456393f3"},
- {file = "orjson-3.10.6-cp310-none-win_amd64.whl", hash = "sha256:df25d9271270ba2133cc88ee83c318372bdc0f2cd6f32e7a450809a111efc45c"},
- {file = "orjson-3.10.6-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b1ec490e10d2a77c345def52599311849fc063ae0e67cf4f84528073152bb2ba"},
- {file = "orjson-3.10.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d43d3feb8f19d07e9f01e5b9be4f28801cf7c60d0fa0d279951b18fae1932b"},
- {file = "orjson-3.10.6-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac3045267e98fe749408eee1593a142e02357c5c99be0802185ef2170086a863"},
- {file = "orjson-3.10.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c27bc6a28ae95923350ab382c57113abd38f3928af3c80be6f2ba7eb8d8db0b0"},
- {file = "orjson-3.10.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d27456491ca79532d11e507cadca37fb8c9324a3976294f68fb1eff2dc6ced5a"},
- {file = "orjson-3.10.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05ac3d3916023745aa3b3b388e91b9166be1ca02b7c7e41045da6d12985685f0"},
- {file = "orjson-3.10.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1335d4ef59ab85cab66fe73fd7a4e881c298ee7f63ede918b7faa1b27cbe5212"},
- {file = "orjson-3.10.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4bbc6d0af24c1575edc79994c20e1b29e6fb3c6a570371306db0993ecf144dc5"},
- {file = "orjson-3.10.6-cp311-none-win32.whl", hash = "sha256:450e39ab1f7694465060a0550b3f6d328d20297bf2e06aa947b97c21e5241fbd"},
- {file = "orjson-3.10.6-cp311-none-win_amd64.whl", hash = "sha256:227df19441372610b20e05bdb906e1742ec2ad7a66ac8350dcfd29a63014a83b"},
- {file = "orjson-3.10.6-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:ea2977b21f8d5d9b758bb3f344a75e55ca78e3ff85595d248eee813ae23ecdfb"},
- {file = "orjson-3.10.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b6f3d167d13a16ed263b52dbfedff52c962bfd3d270b46b7518365bcc2121eed"},
- {file = "orjson-3.10.6-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f710f346e4c44a4e8bdf23daa974faede58f83334289df80bc9cd12fe82573c7"},
- {file = "orjson-3.10.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7275664f84e027dcb1ad5200b8b18373e9c669b2a9ec33d410c40f5ccf4b257e"},
- {file = "orjson-3.10.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0943e4c701196b23c240b3d10ed8ecd674f03089198cf503105b474a4f77f21f"},
- {file = "orjson-3.10.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:446dee5a491b5bc7d8f825d80d9637e7af43f86a331207b9c9610e2f93fee22a"},
- {file = "orjson-3.10.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:64c81456d2a050d380786413786b057983892db105516639cb5d3ee3c7fd5148"},
- {file = "orjson-3.10.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:960db0e31c4e52fa0fc3ecbaea5b2d3b58f379e32a95ae6b0ebeaa25b93dfd34"},
- {file = "orjson-3.10.6-cp312-none-win32.whl", hash = "sha256:a6ea7afb5b30b2317e0bee03c8d34c8181bc5a36f2afd4d0952f378972c4efd5"},
- {file = "orjson-3.10.6-cp312-none-win_amd64.whl", hash = "sha256:874ce88264b7e655dde4aeaacdc8fd772a7962faadfb41abe63e2a4861abc3dc"},
- {file = "orjson-3.10.6-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:66680eae4c4e7fc193d91cfc1353ad6d01b4801ae9b5314f17e11ba55e934183"},
- {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:caff75b425db5ef8e8f23af93c80f072f97b4fb3afd4af44482905c9f588da28"},
- {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3722fddb821b6036fd2a3c814f6bd9b57a89dc6337b9924ecd614ebce3271394"},
- {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2c116072a8533f2fec435fde4d134610f806bdac20188c7bd2081f3e9e0133f"},
- {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6eeb13218c8cf34c61912e9df2de2853f1d009de0e46ea09ccdf3d757896af0a"},
- {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:965a916373382674e323c957d560b953d81d7a8603fbeee26f7b8248638bd48b"},
- {file = "orjson-3.10.6-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:03c95484d53ed8e479cade8628c9cea00fd9d67f5554764a1110e0d5aa2de96e"},
- {file = "orjson-3.10.6-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:e060748a04cccf1e0a6f2358dffea9c080b849a4a68c28b1b907f272b5127e9b"},
- {file = "orjson-3.10.6-cp38-none-win32.whl", hash = "sha256:738dbe3ef909c4b019d69afc19caf6b5ed0e2f1c786b5d6215fbb7539246e4c6"},
- {file = "orjson-3.10.6-cp38-none-win_amd64.whl", hash = "sha256:d40f839dddf6a7d77114fe6b8a70218556408c71d4d6e29413bb5f150a692ff7"},
- {file = "orjson-3.10.6-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:697a35a083c4f834807a6232b3e62c8b280f7a44ad0b759fd4dce748951e70db"},
- {file = "orjson-3.10.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fd502f96bf5ea9a61cbc0b2b5900d0dd68aa0da197179042bdd2be67e51a1e4b"},
- {file = "orjson-3.10.6-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f215789fb1667cdc874c1b8af6a84dc939fd802bf293a8334fce185c79cd359b"},
- {file = "orjson-3.10.6-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2debd8ddce948a8c0938c8c93ade191d2f4ba4649a54302a7da905a81f00b56"},
- {file = "orjson-3.10.6-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5410111d7b6681d4b0d65e0f58a13be588d01b473822483f77f513c7f93bd3b2"},
- {file = "orjson-3.10.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb1f28a137337fdc18384079fa5726810681055b32b92253fa15ae5656e1dddb"},
- {file = "orjson-3.10.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:bf2fbbce5fe7cd1aa177ea3eab2b8e6a6bc6e8592e4279ed3db2d62e57c0e1b2"},
- {file = "orjson-3.10.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:79b9b9e33bd4c517445a62b90ca0cc279b0f1f3970655c3df9e608bc3f91741a"},
- {file = "orjson-3.10.6-cp39-none-win32.whl", hash = "sha256:30b0a09a2014e621b1adf66a4f705f0809358350a757508ee80209b2d8dae219"},
- {file = "orjson-3.10.6-cp39-none-win_amd64.whl", hash = "sha256:49e3bc615652617d463069f91b867a4458114c5b104e13b7ae6872e5f79d0844"},
- {file = "orjson-3.10.6.tar.gz", hash = "sha256:e54b63d0a7c6c54a5f5f726bc93a2078111ef060fec4ecbf34c5db800ca3b3a7"},
+ {file = "orjson-3.10.7-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:74f4544f5a6405b90da8ea724d15ac9c36da4d72a738c64685003337401f5c12"},
+ {file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34a566f22c28222b08875b18b0dfbf8a947e69df21a9ed5c51a6bf91cfb944ac"},
+ {file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf6ba8ebc8ef5792e2337fb0419f8009729335bb400ece005606336b7fd7bab7"},
+ {file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac7cf6222b29fbda9e3a472b41e6a5538b48f2c8f99261eecd60aafbdb60690c"},
+ {file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:de817e2f5fc75a9e7dd350c4b0f54617b280e26d1631811a43e7e968fa71e3e9"},
+ {file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:348bdd16b32556cf8d7257b17cf2bdb7ab7976af4af41ebe79f9796c218f7e91"},
+ {file = "orjson-3.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:479fd0844ddc3ca77e0fd99644c7fe2de8e8be1efcd57705b5c92e5186e8a250"},
+ {file = "orjson-3.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fdf5197a21dd660cf19dfd2a3ce79574588f8f5e2dbf21bda9ee2d2b46924d84"},
+ {file = "orjson-3.10.7-cp310-none-win32.whl", hash = "sha256:d374d36726746c81a49f3ff8daa2898dccab6596864ebe43d50733275c629175"},
+ {file = "orjson-3.10.7-cp310-none-win_amd64.whl", hash = "sha256:cb61938aec8b0ffb6eef484d480188a1777e67b05d58e41b435c74b9d84e0b9c"},
+ {file = "orjson-3.10.7-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:7db8539039698ddfb9a524b4dd19508256107568cdad24f3682d5773e60504a2"},
+ {file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:480f455222cb7a1dea35c57a67578848537d2602b46c464472c995297117fa09"},
+ {file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8a9c9b168b3a19e37fe2778c0003359f07822c90fdff8f98d9d2a91b3144d8e0"},
+ {file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8de062de550f63185e4c1c54151bdddfc5625e37daf0aa1e75d2a1293e3b7d9a"},
+ {file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6b0dd04483499d1de9c8f6203f8975caf17a6000b9c0c54630cef02e44ee624e"},
+ {file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b58d3795dafa334fc8fd46f7c5dc013e6ad06fd5b9a4cc98cb1456e7d3558bd6"},
+ {file = "orjson-3.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:33cfb96c24034a878d83d1a9415799a73dc77480e6c40417e5dda0710d559ee6"},
+ {file = "orjson-3.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e724cebe1fadc2b23c6f7415bad5ee6239e00a69f30ee423f319c6af70e2a5c0"},
+ {file = "orjson-3.10.7-cp311-none-win32.whl", hash = "sha256:82763b46053727a7168d29c772ed5c870fdae2f61aa8a25994c7984a19b1021f"},
+ {file = "orjson-3.10.7-cp311-none-win_amd64.whl", hash = "sha256:eb8d384a24778abf29afb8e41d68fdd9a156cf6e5390c04cc07bbc24b89e98b5"},
+ {file = "orjson-3.10.7-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:44a96f2d4c3af51bfac6bc4ef7b182aa33f2f054fd7f34cc0ee9a320d051d41f"},
+ {file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76ac14cd57df0572453543f8f2575e2d01ae9e790c21f57627803f5e79b0d3c3"},
+ {file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bdbb61dcc365dd9be94e8f7df91975edc9364d6a78c8f7adb69c1cdff318ec93"},
+ {file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b48b3db6bb6e0a08fa8c83b47bc169623f801e5cc4f24442ab2b6617da3b5313"},
+ {file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:23820a1563a1d386414fef15c249040042b8e5d07b40ab3fe3efbfbbcbcb8864"},
+ {file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0c6a008e91d10a2564edbb6ee5069a9e66df3fbe11c9a005cb411f441fd2c09"},
+ {file = "orjson-3.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d352ee8ac1926d6193f602cbe36b1643bbd1bbcb25e3c1a657a4390f3000c9a5"},
+ {file = "orjson-3.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d2d9f990623f15c0ae7ac608103c33dfe1486d2ed974ac3f40b693bad1a22a7b"},
+ {file = "orjson-3.10.7-cp312-none-win32.whl", hash = "sha256:7c4c17f8157bd520cdb7195f75ddbd31671997cbe10aee559c2d613592e7d7eb"},
+ {file = "orjson-3.10.7-cp312-none-win_amd64.whl", hash = "sha256:1d9c0e733e02ada3ed6098a10a8ee0052dd55774de3d9110d29868d24b17faa1"},
+ {file = "orjson-3.10.7-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:77d325ed866876c0fa6492598ec01fe30e803272a6e8b10e992288b009cbe149"},
+ {file = "orjson-3.10.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ea2c232deedcb605e853ae1db2cc94f7390ac776743b699b50b071b02bea6fe"},
+ {file = "orjson-3.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3dcfbede6737fdbef3ce9c37af3fb6142e8e1ebc10336daa05872bfb1d87839c"},
+ {file = "orjson-3.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:11748c135f281203f4ee695b7f80bb1358a82a63905f9f0b794769483ea854ad"},
+ {file = "orjson-3.10.7-cp313-none-win32.whl", hash = "sha256:a7e19150d215c7a13f39eb787d84db274298d3f83d85463e61d277bbd7f401d2"},
+ {file = "orjson-3.10.7-cp313-none-win_amd64.whl", hash = "sha256:eef44224729e9525d5261cc8d28d6b11cafc90e6bd0be2157bde69a52ec83024"},
+ {file = "orjson-3.10.7-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:6ea2b2258eff652c82652d5e0f02bd5e0463a6a52abb78e49ac288827aaa1469"},
+ {file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:430ee4d85841e1483d487e7b81401785a5dfd69db5de01314538f31f8fbf7ee1"},
+ {file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4b6146e439af4c2472c56f8540d799a67a81226e11992008cb47e1267a9b3225"},
+ {file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:084e537806b458911137f76097e53ce7bf5806dda33ddf6aaa66a028f8d43a23"},
+ {file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4829cf2195838e3f93b70fd3b4292156fc5e097aac3739859ac0dcc722b27ac0"},
+ {file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1193b2416cbad1a769f868b1749535d5da47626ac29445803dae7cc64b3f5c98"},
+ {file = "orjson-3.10.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:4e6c3da13e5a57e4b3dca2de059f243ebec705857522f188f0180ae88badd354"},
+ {file = "orjson-3.10.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:c31008598424dfbe52ce8c5b47e0752dca918a4fdc4a2a32004efd9fab41d866"},
+ {file = "orjson-3.10.7-cp38-none-win32.whl", hash = "sha256:7122a99831f9e7fe977dc45784d3b2edc821c172d545e6420c375e5a935f5a1c"},
+ {file = "orjson-3.10.7-cp38-none-win_amd64.whl", hash = "sha256:a763bc0e58504cc803739e7df040685816145a6f3c8a589787084b54ebc9f16e"},
+ {file = "orjson-3.10.7-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e76be12658a6fa376fcd331b1ea4e58f5a06fd0220653450f0d415b8fd0fbe20"},
+ {file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed350d6978d28b92939bfeb1a0570c523f6170efc3f0a0ef1f1df287cd4f4960"},
+ {file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:144888c76f8520e39bfa121b31fd637e18d4cc2f115727865fdf9fa325b10412"},
+ {file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09b2d92fd95ad2402188cf51573acde57eb269eddabaa60f69ea0d733e789fe9"},
+ {file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5b24a579123fa884f3a3caadaed7b75eb5715ee2b17ab5c66ac97d29b18fe57f"},
+ {file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e72591bcfe7512353bd609875ab38050efe3d55e18934e2f18950c108334b4ff"},
+ {file = "orjson-3.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f4db56635b58cd1a200b0a23744ff44206ee6aa428185e2b6c4a65b3197abdcd"},
+ {file = "orjson-3.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0fa5886854673222618638c6df7718ea7fe2f3f2384c452c9ccedc70b4a510a5"},
+ {file = "orjson-3.10.7-cp39-none-win32.whl", hash = "sha256:8272527d08450ab16eb405f47e0f4ef0e5ff5981c3d82afe0efd25dcbef2bcd2"},
+ {file = "orjson-3.10.7-cp39-none-win_amd64.whl", hash = "sha256:974683d4618c0c7dbf4f69c95a979734bf183d0658611760017f6e70a145af58"},
+ {file = "orjson-3.10.7.tar.gz", hash = "sha256:75ef0640403f945f3a1f9f6400686560dbfb0fb5b16589ad62cd477043c4eee3"},
]
[[package]]
@@ -3064,13 +3091,13 @@ type = ["mypy (>=1.8)"]
[[package]]
name = "plotly"
-version = "5.22.0"
+version = "5.23.0"
description = "An open-source, interactive data visualization library for Python"
optional = false
python-versions = ">=3.8"
files = [
- {file = "plotly-5.22.0-py3-none-any.whl", hash = "sha256:68fc1901f098daeb233cc3dd44ec9dc31fb3ca4f4e53189344199c43496ed006"},
- {file = "plotly-5.22.0.tar.gz", hash = "sha256:859fdadbd86b5770ae2466e542b761b247d1c6b49daed765b95bb8c7063e7469"},
+ {file = "plotly-5.23.0-py3-none-any.whl", hash = "sha256:76cbe78f75eddc10c56f5a4ee3e7ccaade7c0a57465546f02098c0caed6c2d1a"},
+ {file = "plotly-5.23.0.tar.gz", hash = "sha256:89e57d003a116303a34de6700862391367dd564222ab71f8531df70279fc0193"},
]
[package.dependencies]
@@ -3094,13 +3121,13 @@ testing = ["pytest", "pytest-benchmark"]
[[package]]
name = "pre-commit"
-version = "3.7.1"
+version = "3.8.0"
description = "A framework for managing and maintaining multi-language pre-commit hooks."
optional = false
python-versions = ">=3.9"
files = [
- {file = "pre_commit-3.7.1-py2.py3-none-any.whl", hash = "sha256:fae36fd1d7ad7d6a5a1c0b0d5adb2ed1a3bda5a21bf6c3e5372073d7a11cd4c5"},
- {file = "pre_commit-3.7.1.tar.gz", hash = "sha256:8ca3ad567bc78a4972a3f1a477e94a79d4597e8140a6e0b651c5e33899c3654a"},
+ {file = "pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f"},
+ {file = "pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af"},
]
[package.dependencies]
@@ -3140,22 +3167,22 @@ wcwidth = "*"
[[package]]
name = "protobuf"
-version = "4.25.3"
+version = "4.25.4"
description = ""
optional = false
python-versions = ">=3.8"
files = [
- {file = "protobuf-4.25.3-cp310-abi3-win32.whl", hash = "sha256:d4198877797a83cbfe9bffa3803602bbe1625dc30d8a097365dbc762e5790faa"},
- {file = "protobuf-4.25.3-cp310-abi3-win_amd64.whl", hash = "sha256:209ba4cc916bab46f64e56b85b090607a676f66b473e6b762e6f1d9d591eb2e8"},
- {file = "protobuf-4.25.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:f1279ab38ecbfae7e456a108c5c0681e4956d5b1090027c1de0f934dfdb4b35c"},
- {file = "protobuf-4.25.3-cp37-abi3-manylinux2014_aarch64.whl", hash = "sha256:e7cb0ae90dd83727f0c0718634ed56837bfeeee29a5f82a7514c03ee1364c019"},
- {file = "protobuf-4.25.3-cp37-abi3-manylinux2014_x86_64.whl", hash = "sha256:7c8daa26095f82482307bc717364e7c13f4f1c99659be82890dcfc215194554d"},
- {file = "protobuf-4.25.3-cp38-cp38-win32.whl", hash = "sha256:f4f118245c4a087776e0a8408be33cf09f6c547442c00395fbfb116fac2f8ac2"},
- {file = "protobuf-4.25.3-cp38-cp38-win_amd64.whl", hash = "sha256:c053062984e61144385022e53678fbded7aea14ebb3e0305ae3592fb219ccfa4"},
- {file = "protobuf-4.25.3-cp39-cp39-win32.whl", hash = "sha256:19b270aeaa0099f16d3ca02628546b8baefe2955bbe23224aaf856134eccf1e4"},
- {file = "protobuf-4.25.3-cp39-cp39-win_amd64.whl", hash = "sha256:e3c97a1555fd6388f857770ff8b9703083de6bf1f9274a002a332d65fbb56c8c"},
- {file = "protobuf-4.25.3-py3-none-any.whl", hash = "sha256:f0700d54bcf45424477e46a9f0944155b46fb0639d69728739c0e47bab83f2b9"},
- {file = "protobuf-4.25.3.tar.gz", hash = "sha256:25b5d0b42fd000320bd7830b349e3b696435f3b329810427a6bcce6a5492cc5c"},
+ {file = "protobuf-4.25.4-cp310-abi3-win32.whl", hash = "sha256:db9fd45183e1a67722cafa5c1da3e85c6492a5383f127c86c4c4aa4845867dc4"},
+ {file = "protobuf-4.25.4-cp310-abi3-win_amd64.whl", hash = "sha256:ba3d8504116a921af46499471c63a85260c1a5fc23333154a427a310e015d26d"},
+ {file = "protobuf-4.25.4-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:eecd41bfc0e4b1bd3fa7909ed93dd14dd5567b98c941d6c1ad08fdcab3d6884b"},
+ {file = "protobuf-4.25.4-cp37-abi3-manylinux2014_aarch64.whl", hash = "sha256:4c8a70fdcb995dcf6c8966cfa3a29101916f7225e9afe3ced4395359955d3835"},
+ {file = "protobuf-4.25.4-cp37-abi3-manylinux2014_x86_64.whl", hash = "sha256:3319e073562e2515c6ddc643eb92ce20809f5d8f10fead3332f71c63be6a7040"},
+ {file = "protobuf-4.25.4-cp38-cp38-win32.whl", hash = "sha256:7e372cbbda66a63ebca18f8ffaa6948455dfecc4e9c1029312f6c2edcd86c4e1"},
+ {file = "protobuf-4.25.4-cp38-cp38-win_amd64.whl", hash = "sha256:051e97ce9fa6067a4546e75cb14f90cf0232dcb3e3d508c448b8d0e4265b61c1"},
+ {file = "protobuf-4.25.4-cp39-cp39-win32.whl", hash = "sha256:90bf6fd378494eb698805bbbe7afe6c5d12c8e17fca817a646cd6a1818c696ca"},
+ {file = "protobuf-4.25.4-cp39-cp39-win_amd64.whl", hash = "sha256:ac79a48d6b99dfed2729ccccee547b34a1d3d63289c71cef056653a846a2240f"},
+ {file = "protobuf-4.25.4-py3-none-any.whl", hash = "sha256:bfbebc1c8e4793cfd58589acfb8a1026be0003e852b9da7db5a4285bde996978"},
+ {file = "protobuf-4.25.4.tar.gz", hash = "sha256:0dc4a62cc4052a036ee2204d26fe4d835c62827c855c8a03f29fe6da146b380d"},
]
[[package]]
@@ -3199,13 +3226,13 @@ files = [
[[package]]
name = "pure-eval"
-version = "0.2.2"
+version = "0.2.3"
description = "Safely evaluate AST nodes without side effects"
optional = false
python-versions = "*"
files = [
- {file = "pure_eval-0.2.2-py3-none-any.whl", hash = "sha256:01eaab343580944bc56080ebe0a674b39ec44a945e6d09ba7db3cb8cec289350"},
- {file = "pure_eval-0.2.2.tar.gz", hash = "sha256:2b45320af6dfaa1750f543d714b6d1c520a1688dec6fd24d339063ce0aaa9ac3"},
+ {file = "pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0"},
+ {file = "pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42"},
]
[package.extras]
@@ -3344,13 +3371,13 @@ typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0"
[[package]]
name = "pydantic-settings"
-version = "2.3.4"
+version = "2.4.0"
description = "Settings management using Pydantic"
optional = false
python-versions = ">=3.8"
files = [
- {file = "pydantic_settings-2.3.4-py3-none-any.whl", hash = "sha256:11ad8bacb68a045f00e4f862c7a718c8a9ec766aa8fd4c32e39a0594b207b53a"},
- {file = "pydantic_settings-2.3.4.tar.gz", hash = "sha256:c5802e3d62b78e82522319bbc9b8f8ffb28ad1c988a99311d04f2a6051fca0a7"},
+ {file = "pydantic_settings-2.4.0-py3-none-any.whl", hash = "sha256:bb6849dc067f1687574c12a639e231f3a6feeed0a12d710c1382045c5db1c315"},
+ {file = "pydantic_settings-2.4.0.tar.gz", hash = "sha256:ed81c3a0f46392b4d7c0a565c05884e6e54b3456e6f0fe4d8814981172dc9a88"},
]
[package.dependencies]
@@ -3358,6 +3385,7 @@ pydantic = ">=2.7.0"
python-dotenv = ">=0.21.0"
[package.extras]
+azure-key-vault = ["azure-identity (>=1.16.0)", "azure-keyvault-secrets (>=4.8.0)"]
toml = ["tomli (>=2.0.1)"]
yaml = ["pyyaml (>=6.0.1)"]
@@ -3396,13 +3424,13 @@ windows-terminal = ["colorama (>=0.4.6)"]
[[package]]
name = "pymdown-extensions"
-version = "10.8.1"
+version = "10.9"
description = "Extension pack for Python Markdown."
optional = false
python-versions = ">=3.8"
files = [
- {file = "pymdown_extensions-10.8.1-py3-none-any.whl", hash = "sha256:f938326115884f48c6059c67377c46cf631c733ef3629b6eed1349989d1b30cb"},
- {file = "pymdown_extensions-10.8.1.tar.gz", hash = "sha256:3ab1db5c9e21728dabf75192d71471f8e50f216627e9a1fa9535ecb0231b9940"},
+ {file = "pymdown_extensions-10.9-py3-none-any.whl", hash = "sha256:d323f7e90d83c86113ee78f3fe62fc9dee5f56b54d912660703ea1816fed5626"},
+ {file = "pymdown_extensions-10.9.tar.gz", hash = "sha256:6ff740bcd99ec4172a938970d42b96128bdc9d4b9bcad72494f29921dc69b753"},
]
[package.dependencies]
@@ -3442,20 +3470,20 @@ cp2110 = ["hidapi"]
[[package]]
name = "pytest"
-version = "8.2.2"
+version = "8.3.2"
description = "pytest: simple powerful testing with Python"
optional = false
python-versions = ">=3.8"
files = [
- {file = "pytest-8.2.2-py3-none-any.whl", hash = "sha256:c434598117762e2bd304e526244f67bf66bbd7b5d6cf22138be51ff661980343"},
- {file = "pytest-8.2.2.tar.gz", hash = "sha256:de4bb8104e201939ccdc688b27a89a7be2079b22e2bd2b07f806b6ba71117977"},
+ {file = "pytest-8.3.2-py3-none-any.whl", hash = "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5"},
+ {file = "pytest-8.3.2.tar.gz", hash = "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce"},
]
[package.dependencies]
colorama = {version = "*", markers = "sys_platform == \"win32\""}
iniconfig = "*"
packaging = "*"
-pluggy = ">=1.5,<2.0"
+pluggy = ">=1.5,<2"
[package.extras]
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
@@ -3550,62 +3578,64 @@ files = [
[[package]]
name = "pyyaml"
-version = "6.0.1"
+version = "6.0.2"
description = "YAML parser and emitter for Python"
optional = false
-python-versions = ">=3.6"
+python-versions = ">=3.8"
files = [
- {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"},
- {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"},
- {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"},
- {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"},
- {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"},
- {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"},
- {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"},
- {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"},
- {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"},
- {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"},
- {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"},
- {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"},
- {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"},
- {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"},
- {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"},
- {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"},
- {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"},
- {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"},
- {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"},
- {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"},
- {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"},
- {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"},
- {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"},
- {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"},
- {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"},
- {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"},
- {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"},
- {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"},
- {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"},
- {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"},
- {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"},
- {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"},
- {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"},
- {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"},
- {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"},
- {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"},
- {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"},
- {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"},
- {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"},
- {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"},
- {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"},
- {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"},
- {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"},
- {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"},
- {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"},
- {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"},
- {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"},
- {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"},
- {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"},
- {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"},
- {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"},
+ {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"},
+ {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"},
+ {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"},
+ {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"},
+ {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"},
+ {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"},
+ {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"},
+ {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"},
+ {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"},
+ {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"},
+ {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"},
+ {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"},
+ {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"},
+ {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"},
+ {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"},
+ {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"},
+ {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"},
+ {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"},
+ {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"},
+ {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"},
+ {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"},
+ {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"},
+ {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"},
+ {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"},
+ {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"},
+ {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"},
+ {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"},
+ {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"},
+ {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"},
+ {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"},
+ {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"},
+ {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"},
+ {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"},
+ {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"},
+ {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"},
+ {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"},
+ {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"},
+ {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"},
+ {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"},
+ {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"},
+ {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"},
+ {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"},
+ {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"},
+ {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"},
+ {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"},
+ {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"},
+ {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"},
+ {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"},
+ {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"},
+ {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"},
+ {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"},
+ {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"},
+ {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"},
]
[[package]]
@@ -3624,99 +3654,120 @@ pyyaml = "*"
[[package]]
name = "pyzmq"
-version = "26.0.3"
+version = "26.1.0"
description = "Python bindings for 0MQ"
optional = false
python-versions = ">=3.7"
files = [
- {file = "pyzmq-26.0.3-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:44dd6fc3034f1eaa72ece33588867df9e006a7303725a12d64c3dff92330f625"},
- {file = "pyzmq-26.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:acb704195a71ac5ea5ecf2811c9ee19ecdc62b91878528302dd0be1b9451cc90"},
- {file = "pyzmq-26.0.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5dbb9c997932473a27afa93954bb77a9f9b786b4ccf718d903f35da3232317de"},
- {file = "pyzmq-26.0.3-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6bcb34f869d431799c3ee7d516554797f7760cb2198ecaa89c3f176f72d062be"},
- {file = "pyzmq-26.0.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:38ece17ec5f20d7d9b442e5174ae9f020365d01ba7c112205a4d59cf19dc38ee"},
- {file = "pyzmq-26.0.3-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:ba6e5e6588e49139a0979d03a7deb9c734bde647b9a8808f26acf9c547cab1bf"},
- {file = "pyzmq-26.0.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:3bf8b000a4e2967e6dfdd8656cd0757d18c7e5ce3d16339e550bd462f4857e59"},
- {file = "pyzmq-26.0.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:2136f64fbb86451dbbf70223635a468272dd20075f988a102bf8a3f194a411dc"},
- {file = "pyzmq-26.0.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e8918973fbd34e7814f59143c5f600ecd38b8038161239fd1a3d33d5817a38b8"},
- {file = "pyzmq-26.0.3-cp310-cp310-win32.whl", hash = "sha256:0aaf982e68a7ac284377d051c742610220fd06d330dcd4c4dbb4cdd77c22a537"},
- {file = "pyzmq-26.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:f1a9b7d00fdf60b4039f4455afd031fe85ee8305b019334b72dcf73c567edc47"},
- {file = "pyzmq-26.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:80b12f25d805a919d53efc0a5ad7c0c0326f13b4eae981a5d7b7cc343318ebb7"},
- {file = "pyzmq-26.0.3-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:a72a84570f84c374b4c287183debc776dc319d3e8ce6b6a0041ce2e400de3f32"},
- {file = "pyzmq-26.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7ca684ee649b55fd8f378127ac8462fb6c85f251c2fb027eb3c887e8ee347bcd"},
- {file = "pyzmq-26.0.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e222562dc0f38571c8b1ffdae9d7adb866363134299264a1958d077800b193b7"},
- {file = "pyzmq-26.0.3-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f17cde1db0754c35a91ac00b22b25c11da6eec5746431d6e5092f0cd31a3fea9"},
- {file = "pyzmq-26.0.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b7c0c0b3244bb2275abe255d4a30c050d541c6cb18b870975553f1fb6f37527"},
- {file = "pyzmq-26.0.3-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:ac97a21de3712afe6a6c071abfad40a6224fd14fa6ff0ff8d0c6e6cd4e2f807a"},
- {file = "pyzmq-26.0.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:88b88282e55fa39dd556d7fc04160bcf39dea015f78e0cecec8ff4f06c1fc2b5"},
- {file = "pyzmq-26.0.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:72b67f966b57dbd18dcc7efbc1c7fc9f5f983e572db1877081f075004614fcdd"},
- {file = "pyzmq-26.0.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f4b6cecbbf3b7380f3b61de3a7b93cb721125dc125c854c14ddc91225ba52f83"},
- {file = "pyzmq-26.0.3-cp311-cp311-win32.whl", hash = "sha256:eed56b6a39216d31ff8cd2f1d048b5bf1700e4b32a01b14379c3b6dde9ce3aa3"},
- {file = "pyzmq-26.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:3191d312c73e3cfd0f0afdf51df8405aafeb0bad71e7ed8f68b24b63c4f36500"},
- {file = "pyzmq-26.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:b6907da3017ef55139cf0e417c5123a84c7332520e73a6902ff1f79046cd3b94"},
- {file = "pyzmq-26.0.3-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:068ca17214038ae986d68f4a7021f97e187ed278ab6dccb79f837d765a54d753"},
- {file = "pyzmq-26.0.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:7821d44fe07335bea256b9f1f41474a642ca55fa671dfd9f00af8d68a920c2d4"},
- {file = "pyzmq-26.0.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eeb438a26d87c123bb318e5f2b3d86a36060b01f22fbdffd8cf247d52f7c9a2b"},
- {file = "pyzmq-26.0.3-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:69ea9d6d9baa25a4dc9cef5e2b77b8537827b122214f210dd925132e34ae9b12"},
- {file = "pyzmq-26.0.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7daa3e1369355766dea11f1d8ef829905c3b9da886ea3152788dc25ee6079e02"},
- {file = "pyzmq-26.0.3-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6ca7a9a06b52d0e38ccf6bca1aeff7be178917893f3883f37b75589d42c4ac20"},
- {file = "pyzmq-26.0.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1b7d0e124948daa4d9686d421ef5087c0516bc6179fdcf8828b8444f8e461a77"},
- {file = "pyzmq-26.0.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:e746524418b70f38550f2190eeee834db8850088c834d4c8406fbb9bc1ae10b2"},
- {file = "pyzmq-26.0.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:6b3146f9ae6af82c47a5282ac8803523d381b3b21caeae0327ed2f7ecb718798"},
- {file = "pyzmq-26.0.3-cp312-cp312-win32.whl", hash = "sha256:2b291d1230845871c00c8462c50565a9cd6026fe1228e77ca934470bb7d70ea0"},
- {file = "pyzmq-26.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:926838a535c2c1ea21c903f909a9a54e675c2126728c21381a94ddf37c3cbddf"},
- {file = "pyzmq-26.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:5bf6c237f8c681dfb91b17f8435b2735951f0d1fad10cc5dfd96db110243370b"},
- {file = "pyzmq-26.0.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0c0991f5a96a8e620f7691e61178cd8f457b49e17b7d9cfa2067e2a0a89fc1d5"},
- {file = "pyzmq-26.0.3-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:dbf012d8fcb9f2cf0643b65df3b355fdd74fc0035d70bb5c845e9e30a3a4654b"},
- {file = "pyzmq-26.0.3-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:01fbfbeb8249a68d257f601deb50c70c929dc2dfe683b754659569e502fbd3aa"},
- {file = "pyzmq-26.0.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c8eb19abe87029c18f226d42b8a2c9efdd139d08f8bf6e085dd9075446db450"},
- {file = "pyzmq-26.0.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:5344b896e79800af86ad643408ca9aa303a017f6ebff8cee5a3163c1e9aec987"},
- {file = "pyzmq-26.0.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:204e0f176fd1d067671157d049466869b3ae1fc51e354708b0dc41cf94e23a3a"},
- {file = "pyzmq-26.0.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:a42db008d58530efa3b881eeee4991146de0b790e095f7ae43ba5cc612decbc5"},
- {file = "pyzmq-26.0.3-cp37-cp37m-win32.whl", hash = "sha256:8d7a498671ca87e32b54cb47c82a92b40130a26c5197d392720a1bce1b3c77cf"},
- {file = "pyzmq-26.0.3-cp37-cp37m-win_amd64.whl", hash = "sha256:3b4032a96410bdc760061b14ed6a33613ffb7f702181ba999df5d16fb96ba16a"},
- {file = "pyzmq-26.0.3-cp38-cp38-macosx_10_15_universal2.whl", hash = "sha256:2cc4e280098c1b192c42a849de8de2c8e0f3a84086a76ec5b07bfee29bda7d18"},
- {file = "pyzmq-26.0.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:5bde86a2ed3ce587fa2b207424ce15b9a83a9fa14422dcc1c5356a13aed3df9d"},
- {file = "pyzmq-26.0.3-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:34106f68e20e6ff253c9f596ea50397dbd8699828d55e8fa18bd4323d8d966e6"},
- {file = "pyzmq-26.0.3-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ebbbd0e728af5db9b04e56389e2299a57ea8b9dd15c9759153ee2455b32be6ad"},
- {file = "pyzmq-26.0.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f6b1d1c631e5940cac5a0b22c5379c86e8df6a4ec277c7a856b714021ab6cfad"},
- {file = "pyzmq-26.0.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e891ce81edd463b3b4c3b885c5603c00141151dd9c6936d98a680c8c72fe5c67"},
- {file = "pyzmq-26.0.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:9b273ecfbc590a1b98f014ae41e5cf723932f3b53ba9367cfb676f838038b32c"},
- {file = "pyzmq-26.0.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b32bff85fb02a75ea0b68f21e2412255b5731f3f389ed9aecc13a6752f58ac97"},
- {file = "pyzmq-26.0.3-cp38-cp38-win32.whl", hash = "sha256:f6c21c00478a7bea93caaaef9e7629145d4153b15a8653e8bb4609d4bc70dbfc"},
- {file = "pyzmq-26.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:3401613148d93ef0fd9aabdbddb212de3db7a4475367f49f590c837355343972"},
- {file = "pyzmq-26.0.3-cp39-cp39-macosx_10_15_universal2.whl", hash = "sha256:2ed8357f4c6e0daa4f3baf31832df8a33334e0fe5b020a61bc8b345a3db7a606"},
- {file = "pyzmq-26.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c1c8f2a2ca45292084c75bb6d3a25545cff0ed931ed228d3a1810ae3758f975f"},
- {file = "pyzmq-26.0.3-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:b63731993cdddcc8e087c64e9cf003f909262b359110070183d7f3025d1c56b5"},
- {file = "pyzmq-26.0.3-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:b3cd31f859b662ac5d7f4226ec7d8bd60384fa037fc02aee6ff0b53ba29a3ba8"},
- {file = "pyzmq-26.0.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:115f8359402fa527cf47708d6f8a0f8234f0e9ca0cab7c18c9c189c194dbf620"},
- {file = "pyzmq-26.0.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:715bdf952b9533ba13dfcf1f431a8f49e63cecc31d91d007bc1deb914f47d0e4"},
- {file = "pyzmq-26.0.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:e1258c639e00bf5e8a522fec6c3eaa3e30cf1c23a2f21a586be7e04d50c9acab"},
- {file = "pyzmq-26.0.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:15c59e780be8f30a60816a9adab900c12a58d79c1ac742b4a8df044ab2a6d920"},
- {file = "pyzmq-26.0.3-cp39-cp39-win32.whl", hash = "sha256:d0cdde3c78d8ab5b46595054e5def32a755fc028685add5ddc7403e9f6de9879"},
- {file = "pyzmq-26.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:ce828058d482ef860746bf532822842e0ff484e27f540ef5c813d516dd8896d2"},
- {file = "pyzmq-26.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:788f15721c64109cf720791714dc14afd0f449d63f3a5487724f024345067381"},
- {file = "pyzmq-26.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:2c18645ef6294d99b256806e34653e86236eb266278c8ec8112622b61db255de"},
- {file = "pyzmq-26.0.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7e6bc96ebe49604df3ec2c6389cc3876cabe475e6bfc84ced1bf4e630662cb35"},
- {file = "pyzmq-26.0.3-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:971e8990c5cc4ddcff26e149398fc7b0f6a042306e82500f5e8db3b10ce69f84"},
- {file = "pyzmq-26.0.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d8416c23161abd94cc7da80c734ad7c9f5dbebdadfdaa77dad78244457448223"},
- {file = "pyzmq-26.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:082a2988364b60bb5de809373098361cf1dbb239623e39e46cb18bc035ed9c0c"},
- {file = "pyzmq-26.0.3-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d57dfbf9737763b3a60d26e6800e02e04284926329aee8fb01049635e957fe81"},
- {file = "pyzmq-26.0.3-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:77a85dca4c2430ac04dc2a2185c2deb3858a34fe7f403d0a946fa56970cf60a1"},
- {file = "pyzmq-26.0.3-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4c82a6d952a1d555bf4be42b6532927d2a5686dd3c3e280e5f63225ab47ac1f5"},
- {file = "pyzmq-26.0.3-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4496b1282c70c442809fc1b151977c3d967bfb33e4e17cedbf226d97de18f709"},
- {file = "pyzmq-26.0.3-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:e4946d6bdb7ba972dfda282f9127e5756d4f299028b1566d1245fa0d438847e6"},
- {file = "pyzmq-26.0.3-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:03c0ae165e700364b266876d712acb1ac02693acd920afa67da2ebb91a0b3c09"},
- {file = "pyzmq-26.0.3-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:3e3070e680f79887d60feeda051a58d0ac36622e1759f305a41059eff62c6da7"},
- {file = "pyzmq-26.0.3-pp38-pypy38_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6ca08b840fe95d1c2bd9ab92dac5685f949fc6f9ae820ec16193e5ddf603c3b2"},
- {file = "pyzmq-26.0.3-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e76654e9dbfb835b3518f9938e565c7806976c07b37c33526b574cc1a1050480"},
- {file = "pyzmq-26.0.3-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:871587bdadd1075b112e697173e946a07d722459d20716ceb3d1bd6c64bd08ce"},
- {file = "pyzmq-26.0.3-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d0a2d1bd63a4ad79483049b26514e70fa618ce6115220da9efdff63688808b17"},
- {file = "pyzmq-26.0.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0270b49b6847f0d106d64b5086e9ad5dc8a902413b5dbbb15d12b60f9c1747a4"},
- {file = "pyzmq-26.0.3-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:703c60b9910488d3d0954ca585c34f541e506a091a41930e663a098d3b794c67"},
- {file = "pyzmq-26.0.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:74423631b6be371edfbf7eabb02ab995c2563fee60a80a30829176842e71722a"},
- {file = "pyzmq-26.0.3-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:4adfbb5451196842a88fda3612e2c0414134874bffb1c2ce83ab4242ec9e027d"},
- {file = "pyzmq-26.0.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:3516119f4f9b8671083a70b6afaa0a070f5683e431ab3dc26e9215620d7ca1ad"},
- {file = "pyzmq-26.0.3.tar.gz", hash = "sha256:dba7d9f2e047dfa2bca3b01f4f84aa5246725203d6284e3790f2ca15fba6b40a"},
+ {file = "pyzmq-26.1.0-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:263cf1e36862310bf5becfbc488e18d5d698941858860c5a8c079d1511b3b18e"},
+ {file = "pyzmq-26.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d5c8b17f6e8f29138678834cf8518049e740385eb2dbf736e8f07fc6587ec682"},
+ {file = "pyzmq-26.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75a95c2358fcfdef3374cb8baf57f1064d73246d55e41683aaffb6cfe6862917"},
+ {file = "pyzmq-26.1.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f99de52b8fbdb2a8f5301ae5fc0f9e6b3ba30d1d5fc0421956967edcc6914242"},
+ {file = "pyzmq-26.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bcbfbab4e1895d58ab7da1b5ce9a327764f0366911ba5b95406c9104bceacb0"},
+ {file = "pyzmq-26.1.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:77ce6a332c7e362cb59b63f5edf730e83590d0ab4e59c2aa5bd79419a42e3449"},
+ {file = "pyzmq-26.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ba0a31d00e8616149a5ab440d058ec2da621e05d744914774c4dde6837e1f545"},
+ {file = "pyzmq-26.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:8b88641384e84a258b740801cd4dbc45c75f148ee674bec3149999adda4a8598"},
+ {file = "pyzmq-26.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2fa76ebcebe555cce90f16246edc3ad83ab65bb7b3d4ce408cf6bc67740c4f88"},
+ {file = "pyzmq-26.1.0-cp310-cp310-win32.whl", hash = "sha256:fbf558551cf415586e91160d69ca6416f3fce0b86175b64e4293644a7416b81b"},
+ {file = "pyzmq-26.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:a7b8aab50e5a288c9724d260feae25eda69582be84e97c012c80e1a5e7e03fb2"},
+ {file = "pyzmq-26.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:08f74904cb066e1178c1ec706dfdb5c6c680cd7a8ed9efebeac923d84c1f13b1"},
+ {file = "pyzmq-26.1.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:46d6800b45015f96b9d92ece229d92f2aef137d82906577d55fadeb9cf5fcb71"},
+ {file = "pyzmq-26.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5bc2431167adc50ba42ea3e5e5f5cd70d93e18ab7b2f95e724dd8e1bd2c38120"},
+ {file = "pyzmq-26.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3bb34bebaa1b78e562931a1687ff663d298013f78f972a534f36c523311a84d"},
+ {file = "pyzmq-26.1.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd3f6329340cef1c7ba9611bd038f2d523cea79f09f9c8f6b0553caba59ec562"},
+ {file = "pyzmq-26.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:471880c4c14e5a056a96cd224f5e71211997d40b4bf5e9fdded55dafab1f98f2"},
+ {file = "pyzmq-26.1.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:ce6f2b66799971cbae5d6547acefa7231458289e0ad481d0be0740535da38d8b"},
+ {file = "pyzmq-26.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0a1f6ea5b1d6cdbb8cfa0536f0d470f12b4b41ad83625012e575f0e3ecfe97f0"},
+ {file = "pyzmq-26.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:b45e6445ac95ecb7d728604bae6538f40ccf4449b132b5428c09918523abc96d"},
+ {file = "pyzmq-26.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:94c4262626424683feea0f3c34951d39d49d354722db2745c42aa6bb50ecd93b"},
+ {file = "pyzmq-26.1.0-cp311-cp311-win32.whl", hash = "sha256:a0f0ab9df66eb34d58205913f4540e2ad17a175b05d81b0b7197bc57d000e829"},
+ {file = "pyzmq-26.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:8efb782f5a6c450589dbab4cb0f66f3a9026286333fe8f3a084399149af52f29"},
+ {file = "pyzmq-26.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:f133d05aaf623519f45e16ab77526e1e70d4e1308e084c2fb4cedb1a0c764bbb"},
+ {file = "pyzmq-26.1.0-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:3d3146b1c3dcc8a1539e7cc094700b2be1e605a76f7c8f0979b6d3bde5ad4072"},
+ {file = "pyzmq-26.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d9270fbf038bf34ffca4855bcda6e082e2c7f906b9eb8d9a8ce82691166060f7"},
+ {file = "pyzmq-26.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:995301f6740a421afc863a713fe62c0aaf564708d4aa057dfdf0f0f56525294b"},
+ {file = "pyzmq-26.1.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7eca8b89e56fb8c6c26dd3e09bd41b24789022acf1cf13358e96f1cafd8cae3"},
+ {file = "pyzmq-26.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d4feb2e83dfe9ace6374a847e98ee9d1246ebadcc0cb765482e272c34e5820"},
+ {file = "pyzmq-26.1.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:d4fafc2eb5d83f4647331267808c7e0c5722c25a729a614dc2b90479cafa78bd"},
+ {file = "pyzmq-26.1.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:58c33dc0e185dd97a9ac0288b3188d1be12b756eda67490e6ed6a75cf9491d79"},
+ {file = "pyzmq-26.1.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:68a0a1d83d33d8367ddddb3e6bb4afbb0f92bd1dac2c72cd5e5ddc86bdafd3eb"},
+ {file = "pyzmq-26.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2ae7c57e22ad881af78075e0cea10a4c778e67234adc65c404391b417a4dda83"},
+ {file = "pyzmq-26.1.0-cp312-cp312-win32.whl", hash = "sha256:347e84fc88cc4cb646597f6d3a7ea0998f887ee8dc31c08587e9c3fd7b5ccef3"},
+ {file = "pyzmq-26.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:9f136a6e964830230912f75b5a116a21fe8e34128dcfd82285aa0ef07cb2c7bd"},
+ {file = "pyzmq-26.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:a4b7a989c8f5a72ab1b2bbfa58105578753ae77b71ba33e7383a31ff75a504c4"},
+ {file = "pyzmq-26.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d416f2088ac8f12daacffbc2e8918ef4d6be8568e9d7155c83b7cebed49d2322"},
+ {file = "pyzmq-26.1.0-cp313-cp313-macosx_10_15_universal2.whl", hash = "sha256:ecb6c88d7946166d783a635efc89f9a1ff11c33d680a20df9657b6902a1d133b"},
+ {file = "pyzmq-26.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:471312a7375571857a089342beccc1a63584315188560c7c0da7e0a23afd8a5c"},
+ {file = "pyzmq-26.1.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e6cea102ffa16b737d11932c426f1dc14b5938cf7bc12e17269559c458ac334"},
+ {file = "pyzmq-26.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec7248673ffc7104b54e4957cee38b2f3075a13442348c8d651777bf41aa45ee"},
+ {file = "pyzmq-26.1.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:0614aed6f87d550b5cecb03d795f4ddbb1544b78d02a4bd5eecf644ec98a39f6"},
+ {file = "pyzmq-26.1.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:e8746ce968be22a8a1801bf4a23e565f9687088580c3ed07af5846580dd97f76"},
+ {file = "pyzmq-26.1.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:7688653574392d2eaeef75ddcd0b2de5b232d8730af29af56c5adf1df9ef8d6f"},
+ {file = "pyzmq-26.1.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:8d4dac7d97f15c653a5fedcafa82626bd6cee1450ccdaf84ffed7ea14f2b07a4"},
+ {file = "pyzmq-26.1.0-cp313-cp313-win32.whl", hash = "sha256:ccb42ca0a4a46232d716779421bbebbcad23c08d37c980f02cc3a6bd115ad277"},
+ {file = "pyzmq-26.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:e1e5d0a25aea8b691a00d6b54b28ac514c8cc0d8646d05f7ca6cb64b97358250"},
+ {file = "pyzmq-26.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:fc82269d24860cfa859b676d18850cbb8e312dcd7eada09e7d5b007e2f3d9eb1"},
+ {file = "pyzmq-26.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:416ac51cabd54f587995c2b05421324700b22e98d3d0aa2cfaec985524d16f1d"},
+ {file = "pyzmq-26.1.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:ff832cce719edd11266ca32bc74a626b814fff236824aa1aeaad399b69fe6eae"},
+ {file = "pyzmq-26.1.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:393daac1bcf81b2a23e696b7b638eedc965e9e3d2112961a072b6cd8179ad2eb"},
+ {file = "pyzmq-26.1.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9869fa984c8670c8ab899a719eb7b516860a29bc26300a84d24d8c1b71eae3ec"},
+ {file = "pyzmq-26.1.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b3b8e36fd4c32c0825b4461372949ecd1585d326802b1321f8b6dc1d7e9318c"},
+ {file = "pyzmq-26.1.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:3ee647d84b83509b7271457bb428cc347037f437ead4b0b6e43b5eba35fec0aa"},
+ {file = "pyzmq-26.1.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:45cb1a70eb00405ce3893041099655265fabcd9c4e1e50c330026e82257892c1"},
+ {file = "pyzmq-26.1.0-cp313-cp313t-musllinux_1_1_i686.whl", hash = "sha256:5cca7b4adb86d7470e0fc96037771981d740f0b4cb99776d5cb59cd0e6684a73"},
+ {file = "pyzmq-26.1.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:91d1a20bdaf3b25f3173ff44e54b1cfbc05f94c9e8133314eb2962a89e05d6e3"},
+ {file = "pyzmq-26.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c0665d85535192098420428c779361b8823d3d7ec4848c6af3abb93bc5c915bf"},
+ {file = "pyzmq-26.1.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:96d7c1d35ee4a495df56c50c83df7af1c9688cce2e9e0edffdbf50889c167595"},
+ {file = "pyzmq-26.1.0-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:b281b5ff5fcc9dcbfe941ac5c7fcd4b6c065adad12d850f95c9d6f23c2652384"},
+ {file = "pyzmq-26.1.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5384c527a9a004445c5074f1e20db83086c8ff1682a626676229aafd9cf9f7d1"},
+ {file = "pyzmq-26.1.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:754c99a9840839375ee251b38ac5964c0f369306eddb56804a073b6efdc0cd88"},
+ {file = "pyzmq-26.1.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:9bdfcb74b469b592972ed881bad57d22e2c0acc89f5e8c146782d0d90fb9f4bf"},
+ {file = "pyzmq-26.1.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:bd13f0231f4788db619347b971ca5f319c5b7ebee151afc7c14632068c6261d3"},
+ {file = "pyzmq-26.1.0-cp37-cp37m-win32.whl", hash = "sha256:c5668dac86a869349828db5fc928ee3f58d450dce2c85607067d581f745e4fb1"},
+ {file = "pyzmq-26.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:ad875277844cfaeca7fe299ddf8c8d8bfe271c3dc1caf14d454faa5cdbf2fa7a"},
+ {file = "pyzmq-26.1.0-cp38-cp38-macosx_10_15_universal2.whl", hash = "sha256:65c6e03cc0222eaf6aad57ff4ecc0a070451e23232bb48db4322cc45602cede0"},
+ {file = "pyzmq-26.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:038ae4ffb63e3991f386e7fda85a9baab7d6617fe85b74a8f9cab190d73adb2b"},
+ {file = "pyzmq-26.1.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:bdeb2c61611293f64ac1073f4bf6723b67d291905308a7de9bb2ca87464e3273"},
+ {file = "pyzmq-26.1.0-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:61dfa5ee9d7df297c859ac82b1226d8fefaf9c5113dc25c2c00ecad6feeeb04f"},
+ {file = "pyzmq-26.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3292d384537b9918010769b82ab3e79fca8b23d74f56fc69a679106a3e2c2cf"},
+ {file = "pyzmq-26.1.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f9499c70c19ff0fbe1007043acb5ad15c1dec7d8e84ab429bca8c87138e8f85c"},
+ {file = "pyzmq-26.1.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:d3dd5523ed258ad58fed7e364c92a9360d1af8a9371e0822bd0146bdf017ef4c"},
+ {file = "pyzmq-26.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:baba2fd199b098c5544ef2536b2499d2e2155392973ad32687024bd8572a7d1c"},
+ {file = "pyzmq-26.1.0-cp38-cp38-win32.whl", hash = "sha256:ddbb2b386128d8eca92bd9ca74e80f73fe263bcca7aa419f5b4cbc1661e19741"},
+ {file = "pyzmq-26.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:79e45a4096ec8388cdeb04a9fa5e9371583bcb826964d55b8b66cbffe7b33c86"},
+ {file = "pyzmq-26.1.0-cp39-cp39-macosx_10_15_universal2.whl", hash = "sha256:add52c78a12196bc0fda2de087ba6c876ea677cbda2e3eba63546b26e8bf177b"},
+ {file = "pyzmq-26.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:98c03bd7f3339ff47de7ea9ac94a2b34580a8d4df69b50128bb6669e1191a895"},
+ {file = "pyzmq-26.1.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:dcc37d9d708784726fafc9c5e1232de655a009dbf97946f117aefa38d5985a0f"},
+ {file = "pyzmq-26.1.0-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:5a6ed52f0b9bf8dcc64cc82cce0607a3dfed1dbb7e8c6f282adfccc7be9781de"},
+ {file = "pyzmq-26.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:451e16ae8bea3d95649317b463c9f95cd9022641ec884e3d63fc67841ae86dfe"},
+ {file = "pyzmq-26.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:906e532c814e1d579138177a00ae835cd6becbf104d45ed9093a3aaf658f6a6a"},
+ {file = "pyzmq-26.1.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:05bacc4f94af468cc82808ae3293390278d5f3375bb20fef21e2034bb9a505b6"},
+ {file = "pyzmq-26.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:57bb2acba798dc3740e913ffadd56b1fcef96f111e66f09e2a8db3050f1f12c8"},
+ {file = "pyzmq-26.1.0-cp39-cp39-win32.whl", hash = "sha256:f774841bb0e8588505002962c02da420bcfb4c5056e87a139c6e45e745c0e2e2"},
+ {file = "pyzmq-26.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:359c533bedc62c56415a1f5fcfd8279bc93453afdb0803307375ecf81c962402"},
+ {file = "pyzmq-26.1.0-cp39-cp39-win_arm64.whl", hash = "sha256:7907419d150b19962138ecec81a17d4892ea440c184949dc29b358bc730caf69"},
+ {file = "pyzmq-26.1.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b24079a14c9596846bf7516fe75d1e2188d4a528364494859106a33d8b48be38"},
+ {file = "pyzmq-26.1.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:59d0acd2976e1064f1b398a00e2c3e77ed0a157529779e23087d4c2fb8aaa416"},
+ {file = "pyzmq-26.1.0-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:911c43a4117915203c4cc8755e0f888e16c4676a82f61caee2f21b0c00e5b894"},
+ {file = "pyzmq-26.1.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10163e586cc609f5f85c9b233195554d77b1e9a0801388907441aaeb22841c5"},
+ {file = "pyzmq-26.1.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:28a8b2abb76042f5fd7bd720f7fea48c0fd3e82e9de0a1bf2c0de3812ce44a42"},
+ {file = "pyzmq-26.1.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:bef24d3e4ae2c985034439f449e3f9e06bf579974ce0e53d8a507a1577d5b2ab"},
+ {file = "pyzmq-26.1.0-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2cd0f4d314f4a2518e8970b6f299ae18cff7c44d4a1fc06fc713f791c3a9e3ea"},
+ {file = "pyzmq-26.1.0-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:fa25a620eed2a419acc2cf10135b995f8f0ce78ad00534d729aa761e4adcef8a"},
+ {file = "pyzmq-26.1.0-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef3b048822dca6d231d8a8ba21069844ae38f5d83889b9b690bf17d2acc7d099"},
+ {file = "pyzmq-26.1.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:9a6847c92d9851b59b9f33f968c68e9e441f9a0f8fc972c5580c5cd7cbc6ee24"},
+ {file = "pyzmq-26.1.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:c9b9305004d7e4e6a824f4f19b6d8f32b3578aad6f19fc1122aaf320cbe3dc83"},
+ {file = "pyzmq-26.1.0-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:63c1d3a65acb2f9c92dce03c4e1758cc552f1ae5c78d79a44e3bb88d2fa71f3a"},
+ {file = "pyzmq-26.1.0-pp38-pypy38_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:d36b8fffe8b248a1b961c86fbdfa0129dfce878731d169ede7fa2631447331be"},
+ {file = "pyzmq-26.1.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67976d12ebfd61a3bc7d77b71a9589b4d61d0422282596cf58c62c3866916544"},
+ {file = "pyzmq-26.1.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:998444debc8816b5d8d15f966e42751032d0f4c55300c48cc337f2b3e4f17d03"},
+ {file = "pyzmq-26.1.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:e5c88b2f13bcf55fee78ea83567b9fe079ba1a4bef8b35c376043440040f7edb"},
+ {file = "pyzmq-26.1.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d906d43e1592be4b25a587b7d96527cb67277542a5611e8ea9e996182fae410"},
+ {file = "pyzmq-26.1.0-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80b0c9942430d731c786545da6be96d824a41a51742e3e374fedd9018ea43106"},
+ {file = "pyzmq-26.1.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:314d11564c00b77f6224d12eb3ddebe926c301e86b648a1835c5b28176c83eab"},
+ {file = "pyzmq-26.1.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:093a1a3cae2496233f14b57f4b485da01b4ff764582c854c0f42c6dd2be37f3d"},
+ {file = "pyzmq-26.1.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:3c397b1b450f749a7e974d74c06d69bd22dd362142f370ef2bd32a684d6b480c"},
+ {file = "pyzmq-26.1.0.tar.gz", hash = "sha256:6c5aeea71f018ebd3b9115c7cb13863dd850e98ca6b9258509de1246461a7e7f"},
]
[package.dependencies]
@@ -3739,90 +3790,90 @@ rpds-py = ">=0.7.0"
[[package]]
name = "regex"
-version = "2024.5.15"
+version = "2024.7.24"
description = "Alternative regular expression module, to replace re."
optional = false
python-versions = ">=3.8"
files = [
- {file = "regex-2024.5.15-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a81e3cfbae20378d75185171587cbf756015ccb14840702944f014e0d93ea09f"},
- {file = "regex-2024.5.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7b59138b219ffa8979013be7bc85bb60c6f7b7575df3d56dc1e403a438c7a3f6"},
- {file = "regex-2024.5.15-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a0bd000c6e266927cb7a1bc39d55be95c4b4f65c5be53e659537537e019232b1"},
- {file = "regex-2024.5.15-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5eaa7ddaf517aa095fa8da0b5015c44d03da83f5bd49c87961e3c997daed0de7"},
- {file = "regex-2024.5.15-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba68168daedb2c0bab7fd7e00ced5ba90aebf91024dea3c88ad5063c2a562cca"},
- {file = "regex-2024.5.15-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6e8d717bca3a6e2064fc3a08df5cbe366369f4b052dcd21b7416e6d71620dca1"},
- {file = "regex-2024.5.15-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1337b7dbef9b2f71121cdbf1e97e40de33ff114801263b275aafd75303bd62b5"},
- {file = "regex-2024.5.15-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f9ebd0a36102fcad2f03696e8af4ae682793a5d30b46c647eaf280d6cfb32796"},
- {file = "regex-2024.5.15-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:9efa1a32ad3a3ea112224897cdaeb6aa00381627f567179c0314f7b65d354c62"},
- {file = "regex-2024.5.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1595f2d10dff3d805e054ebdc41c124753631b6a471b976963c7b28543cf13b0"},
- {file = "regex-2024.5.15-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b802512f3e1f480f41ab5f2cfc0e2f761f08a1f41092d6718868082fc0d27143"},
- {file = "regex-2024.5.15-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a0981022dccabca811e8171f913de05720590c915b033b7e601f35ce4ea7019f"},
- {file = "regex-2024.5.15-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:19068a6a79cf99a19ccefa44610491e9ca02c2be3305c7760d3831d38a467a6f"},
- {file = "regex-2024.5.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1b5269484f6126eee5e687785e83c6b60aad7663dafe842b34691157e5083e53"},
- {file = "regex-2024.5.15-cp310-cp310-win32.whl", hash = "sha256:ada150c5adfa8fbcbf321c30c751dc67d2f12f15bd183ffe4ec7cde351d945b3"},
- {file = "regex-2024.5.15-cp310-cp310-win_amd64.whl", hash = "sha256:ac394ff680fc46b97487941f5e6ae49a9f30ea41c6c6804832063f14b2a5a145"},
- {file = "regex-2024.5.15-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f5b1dff3ad008dccf18e652283f5e5339d70bf8ba7c98bf848ac33db10f7bc7a"},
- {file = "regex-2024.5.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c6a2b494a76983df8e3d3feea9b9ffdd558b247e60b92f877f93a1ff43d26656"},
- {file = "regex-2024.5.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a32b96f15c8ab2e7d27655969a23895eb799de3665fa94349f3b2fbfd547236f"},
- {file = "regex-2024.5.15-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:10002e86e6068d9e1c91eae8295ef690f02f913c57db120b58fdd35a6bb1af35"},
- {file = "regex-2024.5.15-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ec54d5afa89c19c6dd8541a133be51ee1017a38b412b1321ccb8d6ddbeb4cf7d"},
- {file = "regex-2024.5.15-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:10e4ce0dca9ae7a66e6089bb29355d4432caed736acae36fef0fdd7879f0b0cb"},
- {file = "regex-2024.5.15-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e507ff1e74373c4d3038195fdd2af30d297b4f0950eeda6f515ae3d84a1770f"},
- {file = "regex-2024.5.15-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d1f059a4d795e646e1c37665b9d06062c62d0e8cc3c511fe01315973a6542e40"},
- {file = "regex-2024.5.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0721931ad5fe0dda45d07f9820b90b2148ccdd8e45bb9e9b42a146cb4f695649"},
- {file = "regex-2024.5.15-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:833616ddc75ad595dee848ad984d067f2f31be645d603e4d158bba656bbf516c"},
- {file = "regex-2024.5.15-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:287eb7f54fc81546346207c533ad3c2c51a8d61075127d7f6d79aaf96cdee890"},
- {file = "regex-2024.5.15-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:19dfb1c504781a136a80ecd1fff9f16dddf5bb43cec6871778c8a907a085bb3d"},
- {file = "regex-2024.5.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:119af6e56dce35e8dfb5222573b50c89e5508d94d55713c75126b753f834de68"},
- {file = "regex-2024.5.15-cp311-cp311-win32.whl", hash = "sha256:1c1c174d6ec38d6c8a7504087358ce9213d4332f6293a94fbf5249992ba54efa"},
- {file = "regex-2024.5.15-cp311-cp311-win_amd64.whl", hash = "sha256:9e717956dcfd656f5055cc70996ee2cc82ac5149517fc8e1b60261b907740201"},
- {file = "regex-2024.5.15-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:632b01153e5248c134007209b5c6348a544ce96c46005d8456de1d552455b014"},
- {file = "regex-2024.5.15-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e64198f6b856d48192bf921421fdd8ad8eb35e179086e99e99f711957ffedd6e"},
- {file = "regex-2024.5.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:68811ab14087b2f6e0fc0c2bae9ad689ea3584cad6917fc57be6a48bbd012c49"},
- {file = "regex-2024.5.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8ec0c2fea1e886a19c3bee0cd19d862b3aa75dcdfb42ebe8ed30708df64687a"},
- {file = "regex-2024.5.15-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d0c0c0003c10f54a591d220997dd27d953cd9ccc1a7294b40a4be5312be8797b"},
- {file = "regex-2024.5.15-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2431b9e263af1953c55abbd3e2efca67ca80a3de8a0437cb58e2421f8184717a"},
- {file = "regex-2024.5.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a605586358893b483976cffc1723fb0f83e526e8f14c6e6614e75919d9862cf"},
- {file = "regex-2024.5.15-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:391d7f7f1e409d192dba8bcd42d3e4cf9e598f3979cdaed6ab11288da88cb9f2"},
- {file = "regex-2024.5.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9ff11639a8d98969c863d4617595eb5425fd12f7c5ef6621a4b74b71ed8726d5"},
- {file = "regex-2024.5.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4eee78a04e6c67e8391edd4dad3279828dd66ac4b79570ec998e2155d2e59fd5"},
- {file = "regex-2024.5.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8fe45aa3f4aa57faabbc9cb46a93363edd6197cbc43523daea044e9ff2fea83e"},
- {file = "regex-2024.5.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:d0a3d8d6acf0c78a1fff0e210d224b821081330b8524e3e2bc5a68ef6ab5803d"},
- {file = "regex-2024.5.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c486b4106066d502495b3025a0a7251bf37ea9540433940a23419461ab9f2a80"},
- {file = "regex-2024.5.15-cp312-cp312-win32.whl", hash = "sha256:c49e15eac7c149f3670b3e27f1f28a2c1ddeccd3a2812cba953e01be2ab9b5fe"},
- {file = "regex-2024.5.15-cp312-cp312-win_amd64.whl", hash = "sha256:673b5a6da4557b975c6c90198588181029c60793835ce02f497ea817ff647cb2"},
- {file = "regex-2024.5.15-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:87e2a9c29e672fc65523fb47a90d429b70ef72b901b4e4b1bd42387caf0d6835"},
- {file = "regex-2024.5.15-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c3bea0ba8b73b71b37ac833a7f3fd53825924165da6a924aec78c13032f20850"},
- {file = "regex-2024.5.15-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:bfc4f82cabe54f1e7f206fd3d30fda143f84a63fe7d64a81558d6e5f2e5aaba9"},
- {file = "regex-2024.5.15-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5bb9425fe881d578aeca0b2b4b3d314ec88738706f66f219c194d67179337cb"},
- {file = "regex-2024.5.15-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:64c65783e96e563103d641760664125e91bd85d8e49566ee560ded4da0d3e704"},
- {file = "regex-2024.5.15-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cf2430df4148b08fb4324b848672514b1385ae3807651f3567871f130a728cc3"},
- {file = "regex-2024.5.15-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5397de3219a8b08ae9540c48f602996aa6b0b65d5a61683e233af8605c42b0f2"},
- {file = "regex-2024.5.15-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:455705d34b4154a80ead722f4f185b04c4237e8e8e33f265cd0798d0e44825fa"},
- {file = "regex-2024.5.15-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:b2b6f1b3bb6f640c1a92be3bbfbcb18657b125b99ecf141fb3310b5282c7d4ed"},
- {file = "regex-2024.5.15-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:3ad070b823ca5890cab606c940522d05d3d22395d432f4aaaf9d5b1653e47ced"},
- {file = "regex-2024.5.15-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:5b5467acbfc153847d5adb21e21e29847bcb5870e65c94c9206d20eb4e99a384"},
- {file = "regex-2024.5.15-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:e6662686aeb633ad65be2a42b4cb00178b3fbf7b91878f9446075c404ada552f"},
- {file = "regex-2024.5.15-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:2b4c884767504c0e2401babe8b5b7aea9148680d2e157fa28f01529d1f7fcf67"},
- {file = "regex-2024.5.15-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:3cd7874d57f13bf70078f1ff02b8b0aa48d5b9ed25fc48547516c6aba36f5741"},
- {file = "regex-2024.5.15-cp38-cp38-win32.whl", hash = "sha256:e4682f5ba31f475d58884045c1a97a860a007d44938c4c0895f41d64481edbc9"},
- {file = "regex-2024.5.15-cp38-cp38-win_amd64.whl", hash = "sha256:d99ceffa25ac45d150e30bd9ed14ec6039f2aad0ffa6bb87a5936f5782fc1569"},
- {file = "regex-2024.5.15-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:13cdaf31bed30a1e1c2453ef6015aa0983e1366fad2667657dbcac7b02f67133"},
- {file = "regex-2024.5.15-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cac27dcaa821ca271855a32188aa61d12decb6fe45ffe3e722401fe61e323cd1"},
- {file = "regex-2024.5.15-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7dbe2467273b875ea2de38ded4eba86cbcbc9a1a6d0aa11dcf7bd2e67859c435"},
- {file = "regex-2024.5.15-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64f18a9a3513a99c4bef0e3efd4c4a5b11228b48aa80743be822b71e132ae4f5"},
- {file = "regex-2024.5.15-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d347a741ea871c2e278fde6c48f85136c96b8659b632fb57a7d1ce1872547600"},
- {file = "regex-2024.5.15-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1878b8301ed011704aea4c806a3cadbd76f84dece1ec09cc9e4dc934cfa5d4da"},
- {file = "regex-2024.5.15-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4babf07ad476aaf7830d77000874d7611704a7fcf68c9c2ad151f5d94ae4bfc4"},
- {file = "regex-2024.5.15-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:35cb514e137cb3488bce23352af3e12fb0dbedd1ee6e60da053c69fb1b29cc6c"},
- {file = "regex-2024.5.15-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:cdd09d47c0b2efee9378679f8510ee6955d329424c659ab3c5e3a6edea696294"},
- {file = "regex-2024.5.15-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:72d7a99cd6b8f958e85fc6ca5b37c4303294954eac1376535b03c2a43eb72629"},
- {file = "regex-2024.5.15-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:a094801d379ab20c2135529948cb84d417a2169b9bdceda2a36f5f10977ebc16"},
- {file = "regex-2024.5.15-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:c0c18345010870e58238790a6779a1219b4d97bd2e77e1140e8ee5d14df071aa"},
- {file = "regex-2024.5.15-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:16093f563098448ff6b1fa68170e4acbef94e6b6a4e25e10eae8598bb1694b5d"},
- {file = "regex-2024.5.15-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e38a7d4e8f633a33b4c7350fbd8bad3b70bf81439ac67ac38916c4a86b465456"},
- {file = "regex-2024.5.15-cp39-cp39-win32.whl", hash = "sha256:71a455a3c584a88f654b64feccc1e25876066c4f5ef26cd6dd711308aa538694"},
- {file = "regex-2024.5.15-cp39-cp39-win_amd64.whl", hash = "sha256:cab12877a9bdafde5500206d1020a584355a97884dfd388af3699e9137bf7388"},
- {file = "regex-2024.5.15.tar.gz", hash = "sha256:d3ee02d9e5f482cc8309134a91eeaacbdd2261ba111b0fef3748eeb4913e6a2c"},
+ {file = "regex-2024.7.24-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:228b0d3f567fafa0633aee87f08b9276c7062da9616931382993c03808bb68ce"},
+ {file = "regex-2024.7.24-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3426de3b91d1bc73249042742f45c2148803c111d1175b283270177fdf669024"},
+ {file = "regex-2024.7.24-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f273674b445bcb6e4409bf8d1be67bc4b58e8b46fd0d560055d515b8830063cd"},
+ {file = "regex-2024.7.24-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23acc72f0f4e1a9e6e9843d6328177ae3074b4182167e34119ec7233dfeccf53"},
+ {file = "regex-2024.7.24-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65fd3d2e228cae024c411c5ccdffae4c315271eee4a8b839291f84f796b34eca"},
+ {file = "regex-2024.7.24-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c414cbda77dbf13c3bc88b073a1a9f375c7b0cb5e115e15d4b73ec3a2fbc6f59"},
+ {file = "regex-2024.7.24-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf7a89eef64b5455835f5ed30254ec19bf41f7541cd94f266ab7cbd463f00c41"},
+ {file = "regex-2024.7.24-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:19c65b00d42804e3fbea9708f0937d157e53429a39b7c61253ff15670ff62cb5"},
+ {file = "regex-2024.7.24-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:7a5486ca56c8869070a966321d5ab416ff0f83f30e0e2da1ab48815c8d165d46"},
+ {file = "regex-2024.7.24-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6f51f9556785e5a203713f5efd9c085b4a45aecd2a42573e2b5041881b588d1f"},
+ {file = "regex-2024.7.24-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:a4997716674d36a82eab3e86f8fa77080a5d8d96a389a61ea1d0e3a94a582cf7"},
+ {file = "regex-2024.7.24-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:c0abb5e4e8ce71a61d9446040c1e86d4e6d23f9097275c5bd49ed978755ff0fe"},
+ {file = "regex-2024.7.24-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:18300a1d78cf1290fa583cd8b7cde26ecb73e9f5916690cf9d42de569c89b1ce"},
+ {file = "regex-2024.7.24-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:416c0e4f56308f34cdb18c3f59849479dde5b19febdcd6e6fa4d04b6c31c9faa"},
+ {file = "regex-2024.7.24-cp310-cp310-win32.whl", hash = "sha256:fb168b5924bef397b5ba13aabd8cf5df7d3d93f10218d7b925e360d436863f66"},
+ {file = "regex-2024.7.24-cp310-cp310-win_amd64.whl", hash = "sha256:6b9fc7e9cc983e75e2518496ba1afc524227c163e43d706688a6bb9eca41617e"},
+ {file = "regex-2024.7.24-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:382281306e3adaaa7b8b9ebbb3ffb43358a7bbf585fa93821300a418bb975281"},
+ {file = "regex-2024.7.24-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4fdd1384619f406ad9037fe6b6eaa3de2749e2e12084abc80169e8e075377d3b"},
+ {file = "regex-2024.7.24-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3d974d24edb231446f708c455fd08f94c41c1ff4f04bcf06e5f36df5ef50b95a"},
+ {file = "regex-2024.7.24-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a2ec4419a3fe6cf8a4795752596dfe0adb4aea40d3683a132bae9c30b81e8d73"},
+ {file = "regex-2024.7.24-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eb563dd3aea54c797adf513eeec819c4213d7dbfc311874eb4fd28d10f2ff0f2"},
+ {file = "regex-2024.7.24-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:45104baae8b9f67569f0f1dca5e1f1ed77a54ae1cd8b0b07aba89272710db61e"},
+ {file = "regex-2024.7.24-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:994448ee01864501912abf2bad9203bffc34158e80fe8bfb5b031f4f8e16da51"},
+ {file = "regex-2024.7.24-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fac296f99283ac232d8125be932c5cd7644084a30748fda013028c815ba3364"},
+ {file = "regex-2024.7.24-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7e37e809b9303ec3a179085415cb5f418ecf65ec98cdfe34f6a078b46ef823ee"},
+ {file = "regex-2024.7.24-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:01b689e887f612610c869421241e075c02f2e3d1ae93a037cb14f88ab6a8934c"},
+ {file = "regex-2024.7.24-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f6442f0f0ff81775eaa5b05af8a0ffa1dda36e9cf6ec1e0d3d245e8564b684ce"},
+ {file = "regex-2024.7.24-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:871e3ab2838fbcb4e0865a6e01233975df3a15e6fce93b6f99d75cacbd9862d1"},
+ {file = "regex-2024.7.24-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c918b7a1e26b4ab40409820ddccc5d49871a82329640f5005f73572d5eaa9b5e"},
+ {file = "regex-2024.7.24-cp311-cp311-win32.whl", hash = "sha256:2dfbb8baf8ba2c2b9aa2807f44ed272f0913eeeba002478c4577b8d29cde215c"},
+ {file = "regex-2024.7.24-cp311-cp311-win_amd64.whl", hash = "sha256:538d30cd96ed7d1416d3956f94d54e426a8daf7c14527f6e0d6d425fcb4cca52"},
+ {file = "regex-2024.7.24-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:fe4ebef608553aff8deb845c7f4f1d0740ff76fa672c011cc0bacb2a00fbde86"},
+ {file = "regex-2024.7.24-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:74007a5b25b7a678459f06559504f1eec2f0f17bca218c9d56f6a0a12bfffdad"},
+ {file = "regex-2024.7.24-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7df9ea48641da022c2a3c9c641650cd09f0cd15e8908bf931ad538f5ca7919c9"},
+ {file = "regex-2024.7.24-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a1141a1dcc32904c47f6846b040275c6e5de0bf73f17d7a409035d55b76f289"},
+ {file = "regex-2024.7.24-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80c811cfcb5c331237d9bad3bea2c391114588cf4131707e84d9493064d267f9"},
+ {file = "regex-2024.7.24-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7214477bf9bd195894cf24005b1e7b496f46833337b5dedb7b2a6e33f66d962c"},
+ {file = "regex-2024.7.24-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d55588cba7553f0b6ec33130bc3e114b355570b45785cebdc9daed8c637dd440"},
+ {file = "regex-2024.7.24-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:558a57cfc32adcf19d3f791f62b5ff564922942e389e3cfdb538a23d65a6b610"},
+ {file = "regex-2024.7.24-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a512eed9dfd4117110b1881ba9a59b31433caed0c4101b361f768e7bcbaf93c5"},
+ {file = "regex-2024.7.24-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:86b17ba823ea76256b1885652e3a141a99a5c4422f4a869189db328321b73799"},
+ {file = "regex-2024.7.24-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5eefee9bfe23f6df09ffb6dfb23809f4d74a78acef004aa904dc7c88b9944b05"},
+ {file = "regex-2024.7.24-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:731fcd76bbdbf225e2eb85b7c38da9633ad3073822f5ab32379381e8c3c12e94"},
+ {file = "regex-2024.7.24-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:eaef80eac3b4cfbdd6de53c6e108b4c534c21ae055d1dbea2de6b3b8ff3def38"},
+ {file = "regex-2024.7.24-cp312-cp312-win32.whl", hash = "sha256:185e029368d6f89f36e526764cf12bf8d6f0e3a2a7737da625a76f594bdfcbfc"},
+ {file = "regex-2024.7.24-cp312-cp312-win_amd64.whl", hash = "sha256:2f1baff13cc2521bea83ab2528e7a80cbe0ebb2c6f0bfad15be7da3aed443908"},
+ {file = "regex-2024.7.24-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:66b4c0731a5c81921e938dcf1a88e978264e26e6ac4ec96a4d21ae0354581ae0"},
+ {file = "regex-2024.7.24-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:88ecc3afd7e776967fa16c80f974cb79399ee8dc6c96423321d6f7d4b881c92b"},
+ {file = "regex-2024.7.24-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:64bd50cf16bcc54b274e20235bf8edbb64184a30e1e53873ff8d444e7ac656b2"},
+ {file = "regex-2024.7.24-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eb462f0e346fcf41a901a126b50f8781e9a474d3927930f3490f38a6e73b6950"},
+ {file = "regex-2024.7.24-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a82465ebbc9b1c5c50738536fdfa7cab639a261a99b469c9d4c7dcbb2b3f1e57"},
+ {file = "regex-2024.7.24-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:68a8f8c046c6466ac61a36b65bb2395c74451df2ffb8458492ef49900efed293"},
+ {file = "regex-2024.7.24-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac8e84fff5d27420f3c1e879ce9929108e873667ec87e0c8eeb413a5311adfe"},
+ {file = "regex-2024.7.24-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba2537ef2163db9e6ccdbeb6f6424282ae4dea43177402152c67ef869cf3978b"},
+ {file = "regex-2024.7.24-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:43affe33137fcd679bdae93fb25924979517e011f9dea99163f80b82eadc7e53"},
+ {file = "regex-2024.7.24-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:c9bb87fdf2ab2370f21e4d5636e5317775e5d51ff32ebff2cf389f71b9b13750"},
+ {file = "regex-2024.7.24-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:945352286a541406f99b2655c973852da7911b3f4264e010218bbc1cc73168f2"},
+ {file = "regex-2024.7.24-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:8bc593dcce679206b60a538c302d03c29b18e3d862609317cb560e18b66d10cf"},
+ {file = "regex-2024.7.24-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:3f3b6ca8eae6d6c75a6cff525c8530c60e909a71a15e1b731723233331de4169"},
+ {file = "regex-2024.7.24-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:c51edc3541e11fbe83f0c4d9412ef6c79f664a3745fab261457e84465ec9d5a8"},
+ {file = "regex-2024.7.24-cp38-cp38-win32.whl", hash = "sha256:d0a07763776188b4db4c9c7fb1b8c494049f84659bb387b71c73bbc07f189e96"},
+ {file = "regex-2024.7.24-cp38-cp38-win_amd64.whl", hash = "sha256:8fd5afd101dcf86a270d254364e0e8dddedebe6bd1ab9d5f732f274fa00499a5"},
+ {file = "regex-2024.7.24-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:0ffe3f9d430cd37d8fa5632ff6fb36d5b24818c5c986893063b4e5bdb84cdf24"},
+ {file = "regex-2024.7.24-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:25419b70ba00a16abc90ee5fce061228206173231f004437730b67ac77323f0d"},
+ {file = "regex-2024.7.24-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:33e2614a7ce627f0cdf2ad104797d1f68342d967de3695678c0cb84f530709f8"},
+ {file = "regex-2024.7.24-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d33a0021893ede5969876052796165bab6006559ab845fd7b515a30abdd990dc"},
+ {file = "regex-2024.7.24-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:04ce29e2c5fedf296b1a1b0acc1724ba93a36fb14031f3abfb7abda2806c1535"},
+ {file = "regex-2024.7.24-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b16582783f44fbca6fcf46f61347340c787d7530d88b4d590a397a47583f31dd"},
+ {file = "regex-2024.7.24-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:836d3cc225b3e8a943d0b02633fb2f28a66e281290302a79df0e1eaa984ff7c1"},
+ {file = "regex-2024.7.24-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:438d9f0f4bc64e8dea78274caa5af971ceff0f8771e1a2333620969936ba10be"},
+ {file = "regex-2024.7.24-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:973335b1624859cb0e52f96062a28aa18f3a5fc77a96e4a3d6d76e29811a0e6e"},
+ {file = "regex-2024.7.24-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:c5e69fd3eb0b409432b537fe3c6f44ac089c458ab6b78dcec14478422879ec5f"},
+ {file = "regex-2024.7.24-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:fbf8c2f00904eaf63ff37718eb13acf8e178cb940520e47b2f05027f5bb34ce3"},
+ {file = "regex-2024.7.24-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ae2757ace61bc4061b69af19e4689fa4416e1a04840f33b441034202b5cd02d4"},
+ {file = "regex-2024.7.24-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:44fc61b99035fd9b3b9453f1713234e5a7c92a04f3577252b45feefe1b327759"},
+ {file = "regex-2024.7.24-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:84c312cdf839e8b579f504afcd7b65f35d60b6285d892b19adea16355e8343c9"},
+ {file = "regex-2024.7.24-cp39-cp39-win32.whl", hash = "sha256:ca5b2028c2f7af4e13fb9fc29b28d0ce767c38c7facdf64f6c2cd040413055f1"},
+ {file = "regex-2024.7.24-cp39-cp39-win_amd64.whl", hash = "sha256:7c479f5ae937ec9985ecaf42e2e10631551d909f203e31308c12d703922742f9"},
+ {file = "regex-2024.7.24.tar.gz", hash = "sha256:9cfd009eed1a46b27c14039ad5bbc5e71b6367c5b2e6d5f5da0ea91600817506"},
]
[[package]]
@@ -3891,137 +3942,141 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"]
[[package]]
name = "rpds-py"
-version = "0.19.0"
+version = "0.20.0"
description = "Python bindings to Rust's persistent data structures (rpds)"
optional = false
python-versions = ">=3.8"
files = [
- {file = "rpds_py-0.19.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:fb37bd599f031f1a6fb9e58ec62864ccf3ad549cf14bac527dbfa97123edcca4"},
- {file = "rpds_py-0.19.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3384d278df99ec2c6acf701d067147320b864ef6727405d6470838476e44d9e8"},
- {file = "rpds_py-0.19.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e54548e0be3ac117595408fd4ca0ac9278fde89829b0b518be92863b17ff67a2"},
- {file = "rpds_py-0.19.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8eb488ef928cdbc05a27245e52de73c0d7c72a34240ef4d9893fdf65a8c1a955"},
- {file = "rpds_py-0.19.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a5da93debdfe27b2bfc69eefb592e1831d957b9535e0943a0ee8b97996de21b5"},
- {file = "rpds_py-0.19.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:79e205c70afddd41f6ee79a8656aec738492a550247a7af697d5bd1aee14f766"},
- {file = "rpds_py-0.19.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:959179efb3e4a27610e8d54d667c02a9feaa86bbabaf63efa7faa4dfa780d4f1"},
- {file = "rpds_py-0.19.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a6e605bb9edcf010f54f8b6a590dd23a4b40a8cb141255eec2a03db249bc915b"},
- {file = "rpds_py-0.19.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9133d75dc119a61d1a0ded38fb9ba40a00ef41697cc07adb6ae098c875195a3f"},
- {file = "rpds_py-0.19.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dd36b712d35e757e28bf2f40a71e8f8a2d43c8b026d881aa0c617b450d6865c9"},
- {file = "rpds_py-0.19.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:354f3a91718489912f2e0fc331c24eaaf6a4565c080e00fbedb6015857c00582"},
- {file = "rpds_py-0.19.0-cp310-none-win32.whl", hash = "sha256:ebcbf356bf5c51afc3290e491d3722b26aaf5b6af3c1c7f6a1b757828a46e336"},
- {file = "rpds_py-0.19.0-cp310-none-win_amd64.whl", hash = "sha256:75a6076289b2df6c8ecb9d13ff79ae0cad1d5fb40af377a5021016d58cd691ec"},
- {file = "rpds_py-0.19.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6d45080095e585f8c5097897313def60caa2046da202cdb17a01f147fb263b81"},
- {file = "rpds_py-0.19.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c5c9581019c96f865483d031691a5ff1cc455feb4d84fc6920a5ffc48a794d8a"},
- {file = "rpds_py-0.19.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1540d807364c84516417115c38f0119dfec5ea5c0dd9a25332dea60b1d26fc4d"},
- {file = "rpds_py-0.19.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9e65489222b410f79711dc3d2d5003d2757e30874096b2008d50329ea4d0f88c"},
- {file = "rpds_py-0.19.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9da6f400eeb8c36f72ef6646ea530d6d175a4f77ff2ed8dfd6352842274c1d8b"},
- {file = "rpds_py-0.19.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:37f46bb11858717e0efa7893c0f7055c43b44c103e40e69442db5061cb26ed34"},
- {file = "rpds_py-0.19.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:071d4adc734de562bd11d43bd134330fb6249769b2f66b9310dab7460f4bf714"},
- {file = "rpds_py-0.19.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9625367c8955e4319049113ea4f8fee0c6c1145192d57946c6ffcd8fe8bf48dd"},
- {file = "rpds_py-0.19.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e19509145275d46bc4d1e16af0b57a12d227c8253655a46bbd5ec317e941279d"},
- {file = "rpds_py-0.19.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d438e4c020d8c39961deaf58f6913b1bf8832d9b6f62ec35bd93e97807e9cbc"},
- {file = "rpds_py-0.19.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90bf55d9d139e5d127193170f38c584ed3c79e16638890d2e36f23aa1630b952"},
- {file = "rpds_py-0.19.0-cp311-none-win32.whl", hash = "sha256:8d6ad132b1bc13d05ffe5b85e7a01a3998bf3a6302ba594b28d61b8c2cf13aaf"},
- {file = "rpds_py-0.19.0-cp311-none-win_amd64.whl", hash = "sha256:7ec72df7354e6b7f6eb2a17fa6901350018c3a9ad78e48d7b2b54d0412539a67"},
- {file = "rpds_py-0.19.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:5095a7c838a8647c32aa37c3a460d2c48debff7fc26e1136aee60100a8cd8f68"},
- {file = "rpds_py-0.19.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f2f78ef14077e08856e788fa482107aa602636c16c25bdf59c22ea525a785e9"},
- {file = "rpds_py-0.19.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7cc6cb44f8636fbf4a934ca72f3e786ba3c9f9ba4f4d74611e7da80684e48d2"},
- {file = "rpds_py-0.19.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cf902878b4af334a09de7a45badbff0389e7cf8dc2e4dcf5f07125d0b7c2656d"},
- {file = "rpds_py-0.19.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:688aa6b8aa724db1596514751ffb767766e02e5c4a87486ab36b8e1ebc1aedac"},
- {file = "rpds_py-0.19.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57dbc9167d48e355e2569346b5aa4077f29bf86389c924df25c0a8b9124461fb"},
- {file = "rpds_py-0.19.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b4cf5a9497874822341c2ebe0d5850fed392034caadc0bad134ab6822c0925b"},
- {file = "rpds_py-0.19.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8a790d235b9d39c70a466200d506bb33a98e2ee374a9b4eec7a8ac64c2c261fa"},
- {file = "rpds_py-0.19.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1d16089dfa58719c98a1c06f2daceba6d8e3fb9b5d7931af4a990a3c486241cb"},
- {file = "rpds_py-0.19.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:bc9128e74fe94650367fe23f37074f121b9f796cabbd2f928f13e9661837296d"},
- {file = "rpds_py-0.19.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c8f77e661ffd96ff104bebf7d0f3255b02aa5d5b28326f5408d6284c4a8b3248"},
- {file = "rpds_py-0.19.0-cp312-none-win32.whl", hash = "sha256:5f83689a38e76969327e9b682be5521d87a0c9e5a2e187d2bc6be4765f0d4600"},
- {file = "rpds_py-0.19.0-cp312-none-win_amd64.whl", hash = "sha256:06925c50f86da0596b9c3c64c3837b2481337b83ef3519e5db2701df695453a4"},
- {file = "rpds_py-0.19.0-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:52e466bea6f8f3a44b1234570244b1cff45150f59a4acae3fcc5fd700c2993ca"},
- {file = "rpds_py-0.19.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e21cc693045fda7f745c790cb687958161ce172ffe3c5719ca1764e752237d16"},
- {file = "rpds_py-0.19.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b31f059878eb1f5da8b2fd82480cc18bed8dcd7fb8fe68370e2e6285fa86da6"},
- {file = "rpds_py-0.19.0-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1dd46f309e953927dd018567d6a9e2fb84783963650171f6c5fe7e5c41fd5666"},
- {file = "rpds_py-0.19.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34a01a4490e170376cd79258b7f755fa13b1a6c3667e872c8e35051ae857a92b"},
- {file = "rpds_py-0.19.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bcf426a8c38eb57f7bf28932e68425ba86def6e756a5b8cb4731d8e62e4e0223"},
- {file = "rpds_py-0.19.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f68eea5df6347d3f1378ce992d86b2af16ad7ff4dcb4a19ccdc23dea901b87fb"},
- {file = "rpds_py-0.19.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dab8d921b55a28287733263c0e4c7db11b3ee22aee158a4de09f13c93283c62d"},
- {file = "rpds_py-0.19.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:6fe87efd7f47266dfc42fe76dae89060038f1d9cb911f89ae7e5084148d1cc08"},
- {file = "rpds_py-0.19.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:535d4b52524a961d220875688159277f0e9eeeda0ac45e766092bfb54437543f"},
- {file = "rpds_py-0.19.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:8b1a94b8afc154fbe36978a511a1f155f9bd97664e4f1f7a374d72e180ceb0ae"},
- {file = "rpds_py-0.19.0-cp38-none-win32.whl", hash = "sha256:7c98298a15d6b90c8f6e3caa6457f4f022423caa5fa1a1ca7a5e9e512bdb77a4"},
- {file = "rpds_py-0.19.0-cp38-none-win_amd64.whl", hash = "sha256:b0da31853ab6e58a11db3205729133ce0df26e6804e93079dee095be3d681dc1"},
- {file = "rpds_py-0.19.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:5039e3cef7b3e7a060de468a4a60a60a1f31786da94c6cb054e7a3c75906111c"},
- {file = "rpds_py-0.19.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ab1932ca6cb8c7499a4d87cb21ccc0d3326f172cfb6a64021a889b591bb3045c"},
- {file = "rpds_py-0.19.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2afd2164a1e85226fcb6a1da77a5c8896c18bfe08e82e8ceced5181c42d2179"},
- {file = "rpds_py-0.19.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b1c30841f5040de47a0046c243fc1b44ddc87d1b12435a43b8edff7e7cb1e0d0"},
- {file = "rpds_py-0.19.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f757f359f30ec7dcebca662a6bd46d1098f8b9fb1fcd661a9e13f2e8ce343ba1"},
- {file = "rpds_py-0.19.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15e65395a59d2e0e96caf8ee5389ffb4604e980479c32742936ddd7ade914b22"},
- {file = "rpds_py-0.19.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb0f6eb3a320f24b94d177e62f4074ff438f2ad9d27e75a46221904ef21a7b05"},
- {file = "rpds_py-0.19.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b228e693a2559888790936e20f5f88b6e9f8162c681830eda303bad7517b4d5a"},
- {file = "rpds_py-0.19.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2575efaa5d949c9f4e2cdbe7d805d02122c16065bfb8d95c129372d65a291a0b"},
- {file = "rpds_py-0.19.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:5c872814b77a4e84afa293a1bee08c14daed1068b2bb1cc312edbf020bbbca2b"},
- {file = "rpds_py-0.19.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:850720e1b383df199b8433a20e02b25b72f0fded28bc03c5bd79e2ce7ef050be"},
- {file = "rpds_py-0.19.0-cp39-none-win32.whl", hash = "sha256:ce84a7efa5af9f54c0aa7692c45861c1667080814286cacb9958c07fc50294fb"},
- {file = "rpds_py-0.19.0-cp39-none-win_amd64.whl", hash = "sha256:1c26da90b8d06227d7769f34915913911222d24ce08c0ab2d60b354e2d9c7aff"},
- {file = "rpds_py-0.19.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:75969cf900d7be665ccb1622a9aba225cf386bbc9c3bcfeeab9f62b5048f4a07"},
- {file = "rpds_py-0.19.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:8445f23f13339da640d1be8e44e5baf4af97e396882ebbf1692aecd67f67c479"},
- {file = "rpds_py-0.19.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5a7c1062ef8aea3eda149f08120f10795835fc1c8bc6ad948fb9652a113ca55"},
- {file = "rpds_py-0.19.0-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:462b0c18fbb48fdbf980914a02ee38c423a25fcc4cf40f66bacc95a2d2d73bc8"},
- {file = "rpds_py-0.19.0-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3208f9aea18991ac7f2b39721e947bbd752a1abbe79ad90d9b6a84a74d44409b"},
- {file = "rpds_py-0.19.0-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3444fe52b82f122d8a99bf66777aed6b858d392b12f4c317da19f8234db4533"},
- {file = "rpds_py-0.19.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88cb4bac7185a9f0168d38c01d7a00addece9822a52870eee26b8d5b61409213"},
- {file = "rpds_py-0.19.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6b130bd4163c93798a6b9bb96be64a7c43e1cec81126ffa7ffaa106e1fc5cef5"},
- {file = "rpds_py-0.19.0-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:a707b158b4410aefb6b054715545bbb21aaa5d5d0080217290131c49c2124a6e"},
- {file = "rpds_py-0.19.0-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:dc9ac4659456bde7c567107556ab065801622396b435a3ff213daef27b495388"},
- {file = "rpds_py-0.19.0-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:81ea573aa46d3b6b3d890cd3c0ad82105985e6058a4baed03cf92518081eec8c"},
- {file = "rpds_py-0.19.0-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3f148c3f47f7f29a79c38cc5d020edcb5ca780020fab94dbc21f9af95c463581"},
- {file = "rpds_py-0.19.0-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:b0906357f90784a66e89ae3eadc2654f36c580a7d65cf63e6a616e4aec3a81be"},
- {file = "rpds_py-0.19.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f629ecc2db6a4736b5ba95a8347b0089240d69ad14ac364f557d52ad68cf94b0"},
- {file = "rpds_py-0.19.0-pp38-pypy38_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c6feacd1d178c30e5bc37184526e56740342fd2aa6371a28367bad7908d454fc"},
- {file = "rpds_py-0.19.0-pp38-pypy38_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae8b6068ee374fdfab63689be0963333aa83b0815ead5d8648389a8ded593378"},
- {file = "rpds_py-0.19.0-pp38-pypy38_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:78d57546bad81e0da13263e4c9ce30e96dcbe720dbff5ada08d2600a3502e526"},
- {file = "rpds_py-0.19.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8b6683a37338818646af718c9ca2a07f89787551057fae57c4ec0446dc6224b"},
- {file = "rpds_py-0.19.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e8481b946792415adc07410420d6fc65a352b45d347b78fec45d8f8f0d7496f0"},
- {file = "rpds_py-0.19.0-pp38-pypy38_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:bec35eb20792ea64c3c57891bc3ca0bedb2884fbac2c8249d9b731447ecde4fa"},
- {file = "rpds_py-0.19.0-pp38-pypy38_pp73-musllinux_1_2_i686.whl", hash = "sha256:aa5476c3e3a402c37779e95f7b4048db2cb5b0ed0b9d006983965e93f40fe05a"},
- {file = "rpds_py-0.19.0-pp38-pypy38_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:19d02c45f2507b489fd4df7b827940f1420480b3e2e471e952af4d44a1ea8e34"},
- {file = "rpds_py-0.19.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a3e2fd14c5d49ee1da322672375963f19f32b3d5953f0615b175ff7b9d38daed"},
- {file = "rpds_py-0.19.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:93a91c2640645303e874eada51f4f33351b84b351a689d470f8108d0e0694210"},
- {file = "rpds_py-0.19.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5b9fc03bf76a94065299d4a2ecd8dfbae4ae8e2e8098bbfa6ab6413ca267709"},
- {file = "rpds_py-0.19.0-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5a4b07cdf3f84310c08c1de2c12ddadbb7a77568bcb16e95489f9c81074322ed"},
- {file = "rpds_py-0.19.0-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba0ed0dc6763d8bd6e5de5cf0d746d28e706a10b615ea382ac0ab17bb7388633"},
- {file = "rpds_py-0.19.0-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:474bc83233abdcf2124ed3f66230a1c8435896046caa4b0b5ab6013c640803cc"},
- {file = "rpds_py-0.19.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:329c719d31362355a96b435f4653e3b4b061fcc9eba9f91dd40804ca637d914e"},
- {file = "rpds_py-0.19.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ef9101f3f7b59043a34f1dccbb385ca760467590951952d6701df0da9893ca0c"},
- {file = "rpds_py-0.19.0-pp39-pypy39_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:0121803b0f424ee2109d6e1f27db45b166ebaa4b32ff47d6aa225642636cd834"},
- {file = "rpds_py-0.19.0-pp39-pypy39_pp73-musllinux_1_2_i686.whl", hash = "sha256:8344127403dea42f5970adccf6c5957a71a47f522171fafaf4c6ddb41b61703a"},
- {file = "rpds_py-0.19.0-pp39-pypy39_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:443cec402ddd650bb2b885113e1dcedb22b1175c6be223b14246a714b61cd521"},
- {file = "rpds_py-0.19.0.tar.gz", hash = "sha256:4fdc9afadbeb393b4bbbad75481e0ea78e4469f2e1d713a90811700830b553a9"},
+ {file = "rpds_py-0.20.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3ad0fda1635f8439cde85c700f964b23ed5fc2d28016b32b9ee5fe30da5c84e2"},
+ {file = "rpds_py-0.20.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9bb4a0d90fdb03437c109a17eade42dfbf6190408f29b2744114d11586611d6f"},
+ {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c6377e647bbfd0a0b159fe557f2c6c602c159fc752fa316572f012fc0bf67150"},
+ {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb851b7df9dda52dc1415ebee12362047ce771fc36914586b2e9fcbd7d293b3e"},
+ {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1e0f80b739e5a8f54837be5d5c924483996b603d5502bfff79bf33da06164ee2"},
+ {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a8c94dad2e45324fc74dce25e1645d4d14df9a4e54a30fa0ae8bad9a63928e3"},
+ {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8e604fe73ba048c06085beaf51147eaec7df856824bfe7b98657cf436623daf"},
+ {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:df3de6b7726b52966edf29663e57306b23ef775faf0ac01a3e9f4012a24a4140"},
+ {file = "rpds_py-0.20.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf258ede5bc22a45c8e726b29835b9303c285ab46fc7c3a4cc770736b5304c9f"},
+ {file = "rpds_py-0.20.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:55fea87029cded5df854ca7e192ec7bdb7ecd1d9a3f63d5c4eb09148acf4a7ce"},
+ {file = "rpds_py-0.20.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ae94bd0b2f02c28e199e9bc51485d0c5601f58780636185660f86bf80c89af94"},
+ {file = "rpds_py-0.20.0-cp310-none-win32.whl", hash = "sha256:28527c685f237c05445efec62426d285e47a58fb05ba0090a4340b73ecda6dee"},
+ {file = "rpds_py-0.20.0-cp310-none-win_amd64.whl", hash = "sha256:238a2d5b1cad28cdc6ed15faf93a998336eb041c4e440dd7f902528b8891b399"},
+ {file = "rpds_py-0.20.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ac2f4f7a98934c2ed6505aead07b979e6f999389f16b714448fb39bbaa86a489"},
+ {file = "rpds_py-0.20.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:220002c1b846db9afd83371d08d239fdc865e8f8c5795bbaec20916a76db3318"},
+ {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d7919548df3f25374a1f5d01fbcd38dacab338ef5f33e044744b5c36729c8db"},
+ {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:758406267907b3781beee0f0edfe4a179fbd97c0be2e9b1154d7f0a1279cf8e5"},
+ {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3d61339e9f84a3f0767b1995adfb171a0d00a1185192718a17af6e124728e0f5"},
+ {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1259c7b3705ac0a0bd38197565a5d603218591d3f6cee6e614e380b6ba61c6f6"},
+ {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c1dc0f53856b9cc9a0ccca0a7cc61d3d20a7088201c0937f3f4048c1718a209"},
+ {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7e60cb630f674a31f0368ed32b2a6b4331b8350d67de53c0359992444b116dd3"},
+ {file = "rpds_py-0.20.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dbe982f38565bb50cb7fb061ebf762c2f254ca3d8c20d4006878766e84266272"},
+ {file = "rpds_py-0.20.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:514b3293b64187172bc77c8fb0cdae26981618021053b30d8371c3a902d4d5ad"},
+ {file = "rpds_py-0.20.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d0a26ffe9d4dd35e4dfdd1e71f46401cff0181c75ac174711ccff0459135fa58"},
+ {file = "rpds_py-0.20.0-cp311-none-win32.whl", hash = "sha256:89c19a494bf3ad08c1da49445cc5d13d8fefc265f48ee7e7556839acdacf69d0"},
+ {file = "rpds_py-0.20.0-cp311-none-win_amd64.whl", hash = "sha256:c638144ce971df84650d3ed0096e2ae7af8e62ecbbb7b201c8935c370df00a2c"},
+ {file = "rpds_py-0.20.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a84ab91cbe7aab97f7446652d0ed37d35b68a465aeef8fc41932a9d7eee2c1a6"},
+ {file = "rpds_py-0.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:56e27147a5a4c2c21633ff8475d185734c0e4befd1c989b5b95a5d0db699b21b"},
+ {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2580b0c34583b85efec8c5c5ec9edf2dfe817330cc882ee972ae650e7b5ef739"},
+ {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b80d4a7900cf6b66bb9cee5c352b2d708e29e5a37fe9bf784fa97fc11504bf6c"},
+ {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50eccbf054e62a7b2209b28dc7a22d6254860209d6753e6b78cfaeb0075d7bee"},
+ {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:49a8063ea4296b3a7e81a5dfb8f7b2d73f0b1c20c2af401fb0cdf22e14711a96"},
+ {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea438162a9fcbee3ecf36c23e6c68237479f89f962f82dae83dc15feeceb37e4"},
+ {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:18d7585c463087bddcfa74c2ba267339f14f2515158ac4db30b1f9cbdb62c8ef"},
+ {file = "rpds_py-0.20.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d4c7d1a051eeb39f5c9547e82ea27cbcc28338482242e3e0b7768033cb083821"},
+ {file = "rpds_py-0.20.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e4df1e3b3bec320790f699890d41c59d250f6beda159ea3c44c3f5bac1976940"},
+ {file = "rpds_py-0.20.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2cf126d33a91ee6eedc7f3197b53e87a2acdac63602c0f03a02dd69e4b138174"},
+ {file = "rpds_py-0.20.0-cp312-none-win32.whl", hash = "sha256:8bc7690f7caee50b04a79bf017a8d020c1f48c2a1077ffe172abec59870f1139"},
+ {file = "rpds_py-0.20.0-cp312-none-win_amd64.whl", hash = "sha256:0e13e6952ef264c40587d510ad676a988df19adea20444c2b295e536457bc585"},
+ {file = "rpds_py-0.20.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:aa9a0521aeca7d4941499a73ad7d4f8ffa3d1affc50b9ea11d992cd7eff18a29"},
+ {file = "rpds_py-0.20.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4a1f1d51eccb7e6c32ae89243cb352389228ea62f89cd80823ea7dd1b98e0b91"},
+ {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8a86a9b96070674fc88b6f9f71a97d2c1d3e5165574615d1f9168ecba4cecb24"},
+ {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6c8ef2ebf76df43f5750b46851ed1cdf8f109d7787ca40035fe19fbdc1acc5a7"},
+ {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b74b25f024b421d5859d156750ea9a65651793d51b76a2e9238c05c9d5f203a9"},
+ {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57eb94a8c16ab08fef6404301c38318e2c5a32216bf5de453e2714c964c125c8"},
+ {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e1940dae14e715e2e02dfd5b0f64a52e8374a517a1e531ad9412319dc3ac7879"},
+ {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d20277fd62e1b992a50c43f13fbe13277a31f8c9f70d59759c88f644d66c619f"},
+ {file = "rpds_py-0.20.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:06db23d43f26478303e954c34c75182356ca9aa7797d22c5345b16871ab9c45c"},
+ {file = "rpds_py-0.20.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b2a5db5397d82fa847e4c624b0c98fe59d2d9b7cf0ce6de09e4d2e80f8f5b3f2"},
+ {file = "rpds_py-0.20.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5a35df9f5548fd79cb2f52d27182108c3e6641a4feb0f39067911bf2adaa3e57"},
+ {file = "rpds_py-0.20.0-cp313-none-win32.whl", hash = "sha256:fd2d84f40633bc475ef2d5490b9c19543fbf18596dcb1b291e3a12ea5d722f7a"},
+ {file = "rpds_py-0.20.0-cp313-none-win_amd64.whl", hash = "sha256:9bc2d153989e3216b0559251b0c260cfd168ec78b1fac33dd485750a228db5a2"},
+ {file = "rpds_py-0.20.0-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:f2fbf7db2012d4876fb0d66b5b9ba6591197b0f165db8d99371d976546472a24"},
+ {file = "rpds_py-0.20.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1e5f3cd7397c8f86c8cc72d5a791071431c108edd79872cdd96e00abd8497d29"},
+ {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce9845054c13696f7af7f2b353e6b4f676dab1b4b215d7fe5e05c6f8bb06f965"},
+ {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c3e130fd0ec56cb76eb49ef52faead8ff09d13f4527e9b0c400307ff72b408e1"},
+ {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4b16aa0107ecb512b568244ef461f27697164d9a68d8b35090e9b0c1c8b27752"},
+ {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aa7f429242aae2947246587d2964fad750b79e8c233a2367f71b554e9447949c"},
+ {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af0fc424a5842a11e28956e69395fbbeab2c97c42253169d87e90aac2886d751"},
+ {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b8c00a3b1e70c1d3891f0db1b05292747f0dbcfb49c43f9244d04c70fbc40eb8"},
+ {file = "rpds_py-0.20.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:40ce74fc86ee4645d0a225498d091d8bc61f39b709ebef8204cb8b5a464d3c0e"},
+ {file = "rpds_py-0.20.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:4fe84294c7019456e56d93e8ababdad5a329cd25975be749c3f5f558abb48253"},
+ {file = "rpds_py-0.20.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:338ca4539aad4ce70a656e5187a3a31c5204f261aef9f6ab50e50bcdffaf050a"},
+ {file = "rpds_py-0.20.0-cp38-none-win32.whl", hash = "sha256:54b43a2b07db18314669092bb2de584524d1ef414588780261e31e85846c26a5"},
+ {file = "rpds_py-0.20.0-cp38-none-win_amd64.whl", hash = "sha256:a1862d2d7ce1674cffa6d186d53ca95c6e17ed2b06b3f4c476173565c862d232"},
+ {file = "rpds_py-0.20.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:3fde368e9140312b6e8b6c09fb9f8c8c2f00999d1823403ae90cc00480221b22"},
+ {file = "rpds_py-0.20.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9824fb430c9cf9af743cf7aaf6707bf14323fb51ee74425c380f4c846ea70789"},
+ {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:11ef6ce74616342888b69878d45e9f779b95d4bd48b382a229fe624a409b72c5"},
+ {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c52d3f2f82b763a24ef52f5d24358553e8403ce05f893b5347098014f2d9eff2"},
+ {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d35cef91e59ebbeaa45214861874bc6f19eb35de96db73e467a8358d701a96c"},
+ {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d72278a30111e5b5525c1dd96120d9e958464316f55adb030433ea905866f4de"},
+ {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b4c29cbbba378759ac5786730d1c3cb4ec6f8ababf5c42a9ce303dc4b3d08cda"},
+ {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6632f2d04f15d1bd6fe0eedd3b86d9061b836ddca4c03d5cf5c7e9e6b7c14580"},
+ {file = "rpds_py-0.20.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:d0b67d87bb45ed1cd020e8fbf2307d449b68abc45402fe1a4ac9e46c3c8b192b"},
+ {file = "rpds_py-0.20.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:ec31a99ca63bf3cd7f1a5ac9fe95c5e2d060d3c768a09bc1d16e235840861420"},
+ {file = "rpds_py-0.20.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:22e6c9976e38f4d8c4a63bd8a8edac5307dffd3ee7e6026d97f3cc3a2dc02a0b"},
+ {file = "rpds_py-0.20.0-cp39-none-win32.whl", hash = "sha256:569b3ea770c2717b730b61998b6c54996adee3cef69fc28d444f3e7920313cf7"},
+ {file = "rpds_py-0.20.0-cp39-none-win_amd64.whl", hash = "sha256:e6900ecdd50ce0facf703f7a00df12374b74bbc8ad9fe0f6559947fb20f82364"},
+ {file = "rpds_py-0.20.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:617c7357272c67696fd052811e352ac54ed1d9b49ab370261a80d3b6ce385045"},
+ {file = "rpds_py-0.20.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9426133526f69fcaba6e42146b4e12d6bc6c839b8b555097020e2b78ce908dcc"},
+ {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:deb62214c42a261cb3eb04d474f7155279c1a8a8c30ac89b7dcb1721d92c3c02"},
+ {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fcaeb7b57f1a1e071ebd748984359fef83ecb026325b9d4ca847c95bc7311c92"},
+ {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d454b8749b4bd70dd0a79f428731ee263fa6995f83ccb8bada706e8d1d3ff89d"},
+ {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d807dc2051abe041b6649681dce568f8e10668e3c1c6543ebae58f2d7e617855"},
+ {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3c20f0ddeb6e29126d45f89206b8291352b8c5b44384e78a6499d68b52ae511"},
+ {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b7f19250ceef892adf27f0399b9e5afad019288e9be756d6919cb58892129f51"},
+ {file = "rpds_py-0.20.0-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:4f1ed4749a08379555cebf4650453f14452eaa9c43d0a95c49db50c18b7da075"},
+ {file = "rpds_py-0.20.0-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:dcedf0b42bcb4cfff4101d7771a10532415a6106062f005ab97d1d0ab5681c60"},
+ {file = "rpds_py-0.20.0-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:39ed0d010457a78f54090fafb5d108501b5aa5604cc22408fc1c0c77eac14344"},
+ {file = "rpds_py-0.20.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:bb273176be34a746bdac0b0d7e4e2c467323d13640b736c4c477881a3220a989"},
+ {file = "rpds_py-0.20.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f918a1a130a6dfe1d7fe0f105064141342e7dd1611f2e6a21cd2f5c8cb1cfb3e"},
+ {file = "rpds_py-0.20.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:f60012a73aa396be721558caa3a6fd49b3dd0033d1675c6d59c4502e870fcf0c"},
+ {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d2b1ad682a3dfda2a4e8ad8572f3100f95fad98cb99faf37ff0ddfe9cbf9d03"},
+ {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:614fdafe9f5f19c63ea02817fa4861c606a59a604a77c8cdef5aa01d28b97921"},
+ {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fa518bcd7600c584bf42e6617ee8132869e877db2f76bcdc281ec6a4113a53ab"},
+ {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0475242f447cc6cb8a9dd486d68b2ef7fbee84427124c232bff5f63b1fe11e5"},
+ {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f90a4cd061914a60bd51c68bcb4357086991bd0bb93d8aa66a6da7701370708f"},
+ {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:def7400461c3a3f26e49078302e1c1b38f6752342c77e3cf72ce91ca69fb1bc1"},
+ {file = "rpds_py-0.20.0-pp39-pypy39_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:65794e4048ee837494aea3c21a28ad5fc080994dfba5b036cf84de37f7ad5074"},
+ {file = "rpds_py-0.20.0-pp39-pypy39_pp73-musllinux_1_2_i686.whl", hash = "sha256:faefcc78f53a88f3076b7f8be0a8f8d35133a3ecf7f3770895c25f8813460f08"},
+ {file = "rpds_py-0.20.0-pp39-pypy39_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:5b4f105deeffa28bbcdff6c49b34e74903139afa690e35d2d9e3c2c2fba18cec"},
+ {file = "rpds_py-0.20.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:fdfc3a892927458d98f3d55428ae46b921d1f7543b89382fdb483f5640daaec8"},
+ {file = "rpds_py-0.20.0.tar.gz", hash = "sha256:d72a210824facfdaf8768cf2d7ca25a042c30320b3020de2fa04640920d4e121"},
]
[[package]]
name = "ruff"
-version = "0.5.1"
+version = "0.5.7"
description = "An extremely fast Python linter and code formatter, written in Rust."
optional = false
python-versions = ">=3.7"
files = [
- {file = "ruff-0.5.1-py3-none-linux_armv6l.whl", hash = "sha256:6ecf968fcf94d942d42b700af18ede94b07521bd188aaf2cd7bc898dd8cb63b6"},
- {file = "ruff-0.5.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:204fb0a472f00f2e6280a7c8c7c066e11e20e23a37557d63045bf27a616ba61c"},
- {file = "ruff-0.5.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d235968460e8758d1e1297e1de59a38d94102f60cafb4d5382033c324404ee9d"},
- {file = "ruff-0.5.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38beace10b8d5f9b6bdc91619310af6d63dd2019f3fb2d17a2da26360d7962fa"},
- {file = "ruff-0.5.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e478d2f09cf06add143cf8c4540ef77b6599191e0c50ed976582f06e588c994"},
- {file = "ruff-0.5.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f0368d765eec8247b8550251c49ebb20554cc4e812f383ff9f5bf0d5d94190b0"},
- {file = "ruff-0.5.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3a9a9a1b582e37669b0138b7c1d9d60b9edac880b80eb2baba6d0e566bdeca4d"},
- {file = "ruff-0.5.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bdd9f723e16003623423affabcc0a807a66552ee6a29f90eddad87a40c750b78"},
- {file = "ruff-0.5.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be9fd62c1e99539da05fcdc1e90d20f74aec1b7a1613463ed77870057cd6bd96"},
- {file = "ruff-0.5.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e216fc75a80ea1fbd96af94a6233d90190d5b65cc3d5dfacf2bd48c3e067d3e1"},
- {file = "ruff-0.5.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c4c2112e9883a40967827d5c24803525145e7dab315497fae149764979ac7929"},
- {file = "ruff-0.5.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dfaf11c8a116394da3b65cd4b36de30d8552fa45b8119b9ef5ca6638ab964fa3"},
- {file = "ruff-0.5.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d7ceb9b2fe700ee09a0c6b192c5ef03c56eb82a0514218d8ff700f6ade004108"},
- {file = "ruff-0.5.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bac6288e82f6296f82ed5285f597713acb2a6ae26618ffc6b429c597b392535c"},
- {file = "ruff-0.5.1-py3-none-win32.whl", hash = "sha256:5c441d9c24ec09e1cb190a04535c5379b36b73c4bc20aa180c54812c27d1cca4"},
- {file = "ruff-0.5.1-py3-none-win_amd64.whl", hash = "sha256:b1789bf2cd3d1b5a7d38397cac1398ddf3ad7f73f4de01b1e913e2abc7dfc51d"},
- {file = "ruff-0.5.1-py3-none-win_arm64.whl", hash = "sha256:2875b7596a740cbbd492f32d24be73e545a4ce0a3daf51e4f4e609962bfd3cd2"},
- {file = "ruff-0.5.1.tar.gz", hash = "sha256:3164488aebd89b1745b47fd00604fb4358d774465f20d1fcd907f9c0fc1b0655"},
+ {file = "ruff-0.5.7-py3-none-linux_armv6l.whl", hash = "sha256:548992d342fc404ee2e15a242cdbea4f8e39a52f2e7752d0e4cbe88d2d2f416a"},
+ {file = "ruff-0.5.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:00cc8872331055ee017c4f1071a8a31ca0809ccc0657da1d154a1d2abac5c0be"},
+ {file = "ruff-0.5.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf3d86a1fdac1aec8a3417a63587d93f906c678bb9ed0b796da7b59c1114a1e"},
+ {file = "ruff-0.5.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a01c34400097b06cf8a6e61b35d6d456d5bd1ae6961542de18ec81eaf33b4cb8"},
+ {file = "ruff-0.5.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fcc8054f1a717e2213500edaddcf1dbb0abad40d98e1bd9d0ad364f75c763eea"},
+ {file = "ruff-0.5.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7f70284e73f36558ef51602254451e50dd6cc479f8b6f8413a95fcb5db4a55fc"},
+ {file = "ruff-0.5.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:a78ad870ae3c460394fc95437d43deb5c04b5c29297815a2a1de028903f19692"},
+ {file = "ruff-0.5.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9ccd078c66a8e419475174bfe60a69adb36ce04f8d4e91b006f1329d5cd44bcf"},
+ {file = "ruff-0.5.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7e31c9bad4ebf8fdb77b59cae75814440731060a09a0e0077d559a556453acbb"},
+ {file = "ruff-0.5.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d796327eed8e168164346b769dd9a27a70e0298d667b4ecee6877ce8095ec8e"},
+ {file = "ruff-0.5.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4a09ea2c3f7778cc635e7f6edf57d566a8ee8f485f3c4454db7771efb692c499"},
+ {file = "ruff-0.5.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a36d8dcf55b3a3bc353270d544fb170d75d2dff41eba5df57b4e0b67a95bb64e"},
+ {file = "ruff-0.5.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9369c218f789eefbd1b8d82a8cf25017b523ac47d96b2f531eba73770971c9e5"},
+ {file = "ruff-0.5.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b88ca3db7eb377eb24fb7c82840546fb7acef75af4a74bd36e9ceb37a890257e"},
+ {file = "ruff-0.5.7-py3-none-win32.whl", hash = "sha256:33d61fc0e902198a3e55719f4be6b375b28f860b09c281e4bdbf783c0566576a"},
+ {file = "ruff-0.5.7-py3-none-win_amd64.whl", hash = "sha256:083bbcbe6fadb93cd86709037acc510f86eed5a314203079df174c40bbbca6b3"},
+ {file = "ruff-0.5.7-py3-none-win_arm64.whl", hash = "sha256:2dca26154ff9571995107221d0aeaad0e75a77b5a682d6236cf89a58c70b76f4"},
+ {file = "ruff-0.5.7.tar.gz", hash = "sha256:8dfc0a458797f5d9fb622dd0efc52d796f23f0a1493a9527f4e49a550ae9a7e5"},
]
[[package]]
@@ -4167,13 +4222,13 @@ win32 = ["pywin32"]
[[package]]
name = "sentry-sdk"
-version = "2.9.0"
+version = "2.12.0"
description = "Python client for Sentry (https://sentry.io)"
optional = false
python-versions = ">=3.6"
files = [
- {file = "sentry_sdk-2.9.0-py2.py3-none-any.whl", hash = "sha256:0bea5fa8b564cc0d09f2e6f55893e8f70286048b0ffb3a341d5b695d1af0e6ee"},
- {file = "sentry_sdk-2.9.0.tar.gz", hash = "sha256:4c85bad74df9767976afb3eeddc33e0e153300e887d637775a753a35ef99bee6"},
+ {file = "sentry_sdk-2.12.0-py2.py3-none-any.whl", hash = "sha256:7a8d5163d2ba5c5f4464628c6b68f85e86972f7c636acc78aed45c61b98b7a5e"},
+ {file = "sentry_sdk-2.12.0.tar.gz", hash = "sha256:8763840497b817d44c49b3fe3f5f7388d083f2337ffedf008b2cdb63b5c86dc6"},
]
[package.dependencies]
@@ -4203,7 +4258,7 @@ langchain = ["langchain (>=0.0.210)"]
loguru = ["loguru (>=0.5)"]
openai = ["openai (>=1.0.0)", "tiktoken (>=0.3.0)"]
opentelemetry = ["opentelemetry-distro (>=0.35b0)"]
-opentelemetry-experimental = ["opentelemetry-instrumentation-aio-pika (==0.46b0)", "opentelemetry-instrumentation-aiohttp-client (==0.46b0)", "opentelemetry-instrumentation-aiopg (==0.46b0)", "opentelemetry-instrumentation-asgi (==0.46b0)", "opentelemetry-instrumentation-asyncio (==0.46b0)", "opentelemetry-instrumentation-asyncpg (==0.46b0)", "opentelemetry-instrumentation-aws-lambda (==0.46b0)", "opentelemetry-instrumentation-boto (==0.46b0)", "opentelemetry-instrumentation-boto3sqs (==0.46b0)", "opentelemetry-instrumentation-botocore (==0.46b0)", "opentelemetry-instrumentation-cassandra (==0.46b0)", "opentelemetry-instrumentation-celery (==0.46b0)", "opentelemetry-instrumentation-confluent-kafka (==0.46b0)", "opentelemetry-instrumentation-dbapi (==0.46b0)", "opentelemetry-instrumentation-django (==0.46b0)", "opentelemetry-instrumentation-elasticsearch (==0.46b0)", "opentelemetry-instrumentation-falcon (==0.46b0)", "opentelemetry-instrumentation-fastapi (==0.46b0)", "opentelemetry-instrumentation-flask (==0.46b0)", "opentelemetry-instrumentation-grpc (==0.46b0)", "opentelemetry-instrumentation-httpx (==0.46b0)", "opentelemetry-instrumentation-jinja2 (==0.46b0)", "opentelemetry-instrumentation-kafka-python (==0.46b0)", "opentelemetry-instrumentation-logging (==0.46b0)", "opentelemetry-instrumentation-mysql (==0.46b0)", "opentelemetry-instrumentation-mysqlclient (==0.46b0)", "opentelemetry-instrumentation-pika (==0.46b0)", "opentelemetry-instrumentation-psycopg (==0.46b0)", "opentelemetry-instrumentation-psycopg2 (==0.46b0)", "opentelemetry-instrumentation-pymemcache (==0.46b0)", "opentelemetry-instrumentation-pymongo (==0.46b0)", "opentelemetry-instrumentation-pymysql (==0.46b0)", "opentelemetry-instrumentation-pyramid (==0.46b0)", "opentelemetry-instrumentation-redis (==0.46b0)", "opentelemetry-instrumentation-remoulade (==0.46b0)", "opentelemetry-instrumentation-requests (==0.46b0)", "opentelemetry-instrumentation-sklearn (==0.46b0)", "opentelemetry-instrumentation-sqlalchemy (==0.46b0)", "opentelemetry-instrumentation-sqlite3 (==0.46b0)", "opentelemetry-instrumentation-starlette (==0.46b0)", "opentelemetry-instrumentation-system-metrics (==0.46b0)", "opentelemetry-instrumentation-threading (==0.46b0)", "opentelemetry-instrumentation-tornado (==0.46b0)", "opentelemetry-instrumentation-tortoiseorm (==0.46b0)", "opentelemetry-instrumentation-urllib (==0.46b0)", "opentelemetry-instrumentation-urllib3 (==0.46b0)", "opentelemetry-instrumentation-wsgi (==0.46b0)"]
+opentelemetry-experimental = ["opentelemetry-distro"]
pure-eval = ["asttokens", "executing", "pure-eval"]
pymongo = ["pymongo (>=3.1)"]
pyspark = ["pyspark (>=2.4.4)"]
@@ -4317,18 +4372,19 @@ test = ["pytest"]
[[package]]
name = "setuptools"
-version = "70.3.0"
+version = "72.1.0"
description = "Easily download, build, install, upgrade, and uninstall Python packages"
optional = false
python-versions = ">=3.8"
files = [
- {file = "setuptools-70.3.0-py3-none-any.whl", hash = "sha256:fe384da74336c398e0d956d1cae0669bc02eed936cdb1d49b57de1990dc11ffc"},
- {file = "setuptools-70.3.0.tar.gz", hash = "sha256:f171bab1dfbc86b132997f26a119f6056a57950d058587841a0082e8830f9dc5"},
+ {file = "setuptools-72.1.0-py3-none-any.whl", hash = "sha256:5a03e1860cf56bb6ef48ce186b0e557fdba433237481a9a625176c2831be15d1"},
+ {file = "setuptools-72.1.0.tar.gz", hash = "sha256:8d243eff56d095e5817f796ede6ae32941278f542e0f941867cc05ae52b162ec"},
]
[package.extras]
+core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.text (>=3.7)", "more-itertools (>=8.8)", "ordered-set (>=3.1.1)", "packaging (>=24)", "platformdirs (>=2.6.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"]
doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"]
-test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "mypy (==1.10.0)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (>=0.3.2)", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"]
+test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "mypy (==1.11.*)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (<0.4)", "pytest-ruff (>=0.2.1)", "pytest-ruff (>=0.3.2)", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"]
[[package]]
name = "six"
@@ -4435,13 +4491,13 @@ tomli = {version = ">=2.0.1,<3.0.0", markers = "python_version >= \"3.7\" and py
[[package]]
name = "tenacity"
-version = "8.5.0"
+version = "9.0.0"
description = "Retry code until it succeeds"
optional = false
python-versions = ">=3.8"
files = [
- {file = "tenacity-8.5.0-py3-none-any.whl", hash = "sha256:b594c2a5945830c267ce6b79a166228323ed52718f30302c1359836112346687"},
- {file = "tenacity-8.5.0.tar.gz", hash = "sha256:8bc6c0c8a09b31e6cad13c47afbed1a567518250a9a171418582ed8d9c20ca78"},
+ {file = "tenacity-9.0.0-py3-none-any.whl", hash = "sha256:93de0c98785b27fcf659856aa9f54bfbd399e29969b0621bc7f762bd441b4539"},
+ {file = "tenacity-9.0.0.tar.gz", hash = "sha256:807f37ca97d62aa361264d497b0e31e92b8027044942bfa756160d908320d73b"},
]
[package.extras]
@@ -4483,13 +4539,13 @@ files = [
[[package]]
name = "tensorboard-plugin-profile"
-version = "2.15.1"
+version = "2.17.0"
description = "Profile Tensorboard Plugin"
optional = false
-python-versions = ">= 2.7, != 3.0.*, != 3.1.*"
+python-versions = "!=3.0.*,!=3.1.*,>=2.7"
files = [
- {file = "tensorboard_plugin_profile-2.15.1-py3-none-any.whl", hash = "sha256:93231c3330d19c0647279eb296b7a1f20ea70dfd366a9fe837b016aa2cc4190c"},
- {file = "tensorboard_plugin_profile-2.15.1.tar.gz", hash = "sha256:84bb33e446eb4a9c0616f669fc6a42cdd40eadd9ae1d74bf756f4f0479993273"},
+ {file = "tensorboard_plugin_profile-2.17.0-py3-none-any.whl", hash = "sha256:47f04031c8746869755132c6570fd73b8c4101a1ef7343dd8787b53c9498a2f8"},
+ {file = "tensorboard_plugin_profile-2.17.0.tar.gz", hash = "sha256:a7bb4eae9f41ca3606bb2fb43ffe04ab5dbb872fc5fc26a76086ebc608ef58ed"},
]
[package.dependencies]
@@ -4666,6 +4722,17 @@ webencodings = ">=0.4"
doc = ["sphinx", "sphinx_rtd_theme"]
test = ["pytest", "ruff"]
+[[package]]
+name = "toml"
+version = "0.10.2"
+description = "Python Library for Tom's Obvious, Minimal Language"
+optional = false
+python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
+files = [
+ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},
+ {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
+]
+
[[package]]
name = "tomli"
version = "2.0.1"
@@ -4710,13 +4777,13 @@ files = [
[[package]]
name = "tqdm"
-version = "4.66.4"
+version = "4.66.5"
description = "Fast, Extensible Progress Meter"
optional = false
python-versions = ">=3.7"
files = [
- {file = "tqdm-4.66.4-py3-none-any.whl", hash = "sha256:b75ca56b413b030bc3f00af51fd2c1a1a5eac6a0c1cca83cbb37a5c52abce644"},
- {file = "tqdm-4.66.4.tar.gz", hash = "sha256:e4d936c9de8727928f3be6079590e97d9abfe8d39a590be678eb5919ffc186bb"},
+ {file = "tqdm-4.66.5-py3-none-any.whl", hash = "sha256:90279a3770753eafc9194a0364852159802111925aa30eb3f9d85b0e805ac7cd"},
+ {file = "tqdm-4.66.5.tar.gz", hash = "sha256:e1020aef2e5096702d8a025ac7d16b1577279c9d63f8375b63083e9a5f0fcbad"},
]
[package.dependencies]
@@ -4867,43 +4934,46 @@ sweeps = ["sweeps (>=0.2.0)"]
[[package]]
name = "watchdog"
-version = "4.0.1"
+version = "4.0.2"
description = "Filesystem events monitoring"
optional = false
python-versions = ">=3.8"
files = [
- {file = "watchdog-4.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:da2dfdaa8006eb6a71051795856bedd97e5b03e57da96f98e375682c48850645"},
- {file = "watchdog-4.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e93f451f2dfa433d97765ca2634628b789b49ba8b504fdde5837cdcf25fdb53b"},
- {file = "watchdog-4.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ef0107bbb6a55f5be727cfc2ef945d5676b97bffb8425650dadbb184be9f9a2b"},
- {file = "watchdog-4.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:17e32f147d8bf9657e0922c0940bcde863b894cd871dbb694beb6704cfbd2fb5"},
- {file = "watchdog-4.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:03e70d2df2258fb6cb0e95bbdbe06c16e608af94a3ffbd2b90c3f1e83eb10767"},
- {file = "watchdog-4.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:123587af84260c991dc5f62a6e7ef3d1c57dfddc99faacee508c71d287248459"},
- {file = "watchdog-4.0.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:093b23e6906a8b97051191a4a0c73a77ecc958121d42346274c6af6520dec175"},
- {file = "watchdog-4.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:611be3904f9843f0529c35a3ff3fd617449463cb4b73b1633950b3d97fa4bfb7"},
- {file = "watchdog-4.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:62c613ad689ddcb11707f030e722fa929f322ef7e4f18f5335d2b73c61a85c28"},
- {file = "watchdog-4.0.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:d4925e4bf7b9bddd1c3de13c9b8a2cdb89a468f640e66fbfabaf735bd85b3e35"},
- {file = "watchdog-4.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cad0bbd66cd59fc474b4a4376bc5ac3fc698723510cbb64091c2a793b18654db"},
- {file = "watchdog-4.0.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a3c2c317a8fb53e5b3d25790553796105501a235343f5d2bf23bb8649c2c8709"},
- {file = "watchdog-4.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c9904904b6564d4ee8a1ed820db76185a3c96e05560c776c79a6ce5ab71888ba"},
- {file = "watchdog-4.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:667f3c579e813fcbad1b784db7a1aaa96524bed53437e119f6a2f5de4db04235"},
- {file = "watchdog-4.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d10a681c9a1d5a77e75c48a3b8e1a9f2ae2928eda463e8d33660437705659682"},
- {file = "watchdog-4.0.1-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:0144c0ea9997b92615af1d94afc0c217e07ce2c14912c7b1a5731776329fcfc7"},
- {file = "watchdog-4.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:998d2be6976a0ee3a81fb8e2777900c28641fb5bfbd0c84717d89bca0addcdc5"},
- {file = "watchdog-4.0.1-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:e7921319fe4430b11278d924ef66d4daa469fafb1da679a2e48c935fa27af193"},
- {file = "watchdog-4.0.1-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:f0de0f284248ab40188f23380b03b59126d1479cd59940f2a34f8852db710625"},
- {file = "watchdog-4.0.1-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:bca36be5707e81b9e6ce3208d92d95540d4ca244c006b61511753583c81c70dd"},
- {file = "watchdog-4.0.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:ab998f567ebdf6b1da7dc1e5accfaa7c6992244629c0fdaef062f43249bd8dee"},
- {file = "watchdog-4.0.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:dddba7ca1c807045323b6af4ff80f5ddc4d654c8bce8317dde1bd96b128ed253"},
- {file = "watchdog-4.0.1-py3-none-manylinux2014_armv7l.whl", hash = "sha256:4513ec234c68b14d4161440e07f995f231be21a09329051e67a2118a7a612d2d"},
- {file = "watchdog-4.0.1-py3-none-manylinux2014_i686.whl", hash = "sha256:4107ac5ab936a63952dea2a46a734a23230aa2f6f9db1291bf171dac3ebd53c6"},
- {file = "watchdog-4.0.1-py3-none-manylinux2014_ppc64.whl", hash = "sha256:6e8c70d2cd745daec2a08734d9f63092b793ad97612470a0ee4cbb8f5f705c57"},
- {file = "watchdog-4.0.1-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:f27279d060e2ab24c0aa98363ff906d2386aa6c4dc2f1a374655d4e02a6c5e5e"},
- {file = "watchdog-4.0.1-py3-none-manylinux2014_s390x.whl", hash = "sha256:f8affdf3c0f0466e69f5b3917cdd042f89c8c63aebdb9f7c078996f607cdb0f5"},
- {file = "watchdog-4.0.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:ac7041b385f04c047fcc2951dc001671dee1b7e0615cde772e84b01fbf68ee84"},
- {file = "watchdog-4.0.1-py3-none-win32.whl", hash = "sha256:206afc3d964f9a233e6ad34618ec60b9837d0582b500b63687e34011e15bb429"},
- {file = "watchdog-4.0.1-py3-none-win_amd64.whl", hash = "sha256:7577b3c43e5909623149f76b099ac49a1a01ca4e167d1785c76eb52fa585745a"},
- {file = "watchdog-4.0.1-py3-none-win_ia64.whl", hash = "sha256:d7b9f5f3299e8dd230880b6c55504a1f69cf1e4316275d1b215ebdd8187ec88d"},
- {file = "watchdog-4.0.1.tar.gz", hash = "sha256:eebaacf674fa25511e8867028d281e602ee6500045b57f43b08778082f7f8b44"},
+ {file = "watchdog-4.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ede7f010f2239b97cc79e6cb3c249e72962404ae3865860855d5cbe708b0fd22"},
+ {file = "watchdog-4.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a2cffa171445b0efa0726c561eca9a27d00a1f2b83846dbd5a4f639c4f8ca8e1"},
+ {file = "watchdog-4.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c50f148b31b03fbadd6d0b5980e38b558046b127dc483e5e4505fcef250f9503"},
+ {file = "watchdog-4.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7c7d4bf585ad501c5f6c980e7be9c4f15604c7cc150e942d82083b31a7548930"},
+ {file = "watchdog-4.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:914285126ad0b6eb2258bbbcb7b288d9dfd655ae88fa28945be05a7b475a800b"},
+ {file = "watchdog-4.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:984306dc4720da5498b16fc037b36ac443816125a3705dfde4fd90652d8028ef"},
+ {file = "watchdog-4.0.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:1cdcfd8142f604630deef34722d695fb455d04ab7cfe9963055df1fc69e6727a"},
+ {file = "watchdog-4.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d7ab624ff2f663f98cd03c8b7eedc09375a911794dfea6bf2a359fcc266bff29"},
+ {file = "watchdog-4.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:132937547a716027bd5714383dfc40dc66c26769f1ce8a72a859d6a48f371f3a"},
+ {file = "watchdog-4.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:cd67c7df93eb58f360c43802acc945fa8da70c675b6fa37a241e17ca698ca49b"},
+ {file = "watchdog-4.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bcfd02377be80ef3b6bc4ce481ef3959640458d6feaae0bd43dd90a43da90a7d"},
+ {file = "watchdog-4.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:980b71510f59c884d684b3663d46e7a14b457c9611c481e5cef08f4dd022eed7"},
+ {file = "watchdog-4.0.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:aa160781cafff2719b663c8a506156e9289d111d80f3387cf3af49cedee1f040"},
+ {file = "watchdog-4.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f6ee8dedd255087bc7fe82adf046f0b75479b989185fb0bdf9a98b612170eac7"},
+ {file = "watchdog-4.0.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:0b4359067d30d5b864e09c8597b112fe0a0a59321a0f331498b013fb097406b4"},
+ {file = "watchdog-4.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:770eef5372f146997638d737c9a3c597a3b41037cfbc5c41538fc27c09c3a3f9"},
+ {file = "watchdog-4.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:eeea812f38536a0aa859972d50c76e37f4456474b02bd93674d1947cf1e39578"},
+ {file = "watchdog-4.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b2c45f6e1e57ebb4687690c05bc3a2c1fb6ab260550c4290b8abb1335e0fd08b"},
+ {file = "watchdog-4.0.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:10b6683df70d340ac3279eff0b2766813f00f35a1d37515d2c99959ada8f05fa"},
+ {file = "watchdog-4.0.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:f7c739888c20f99824f7aa9d31ac8a97353e22d0c0e54703a547a218f6637eb3"},
+ {file = "watchdog-4.0.2-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:c100d09ac72a8a08ddbf0629ddfa0b8ee41740f9051429baa8e31bb903ad7508"},
+ {file = "watchdog-4.0.2-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:f5315a8c8dd6dd9425b974515081fc0aadca1d1d61e078d2246509fd756141ee"},
+ {file = "watchdog-4.0.2-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:2d468028a77b42cc685ed694a7a550a8d1771bb05193ba7b24006b8241a571a1"},
+ {file = "watchdog-4.0.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:f15edcae3830ff20e55d1f4e743e92970c847bcddc8b7509bcd172aa04de506e"},
+ {file = "watchdog-4.0.2-py3-none-manylinux2014_aarch64.whl", hash = "sha256:936acba76d636f70db8f3c66e76aa6cb5136a936fc2a5088b9ce1c7a3508fc83"},
+ {file = "watchdog-4.0.2-py3-none-manylinux2014_armv7l.whl", hash = "sha256:e252f8ca942a870f38cf785aef420285431311652d871409a64e2a0a52a2174c"},
+ {file = "watchdog-4.0.2-py3-none-manylinux2014_i686.whl", hash = "sha256:0e83619a2d5d436a7e58a1aea957a3c1ccbf9782c43c0b4fed80580e5e4acd1a"},
+ {file = "watchdog-4.0.2-py3-none-manylinux2014_ppc64.whl", hash = "sha256:88456d65f207b39f1981bf772e473799fcdc10801062c36fd5ad9f9d1d463a73"},
+ {file = "watchdog-4.0.2-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:32be97f3b75693a93c683787a87a0dc8db98bb84701539954eef991fb35f5fbc"},
+ {file = "watchdog-4.0.2-py3-none-manylinux2014_s390x.whl", hash = "sha256:c82253cfc9be68e3e49282831afad2c1f6593af80c0daf1287f6a92657986757"},
+ {file = "watchdog-4.0.2-py3-none-manylinux2014_x86_64.whl", hash = "sha256:c0b14488bd336c5b1845cee83d3e631a1f8b4e9c5091ec539406e4a324f882d8"},
+ {file = "watchdog-4.0.2-py3-none-win32.whl", hash = "sha256:0d8a7e523ef03757a5aa29f591437d64d0d894635f8a50f370fe37f913ce4e19"},
+ {file = "watchdog-4.0.2-py3-none-win_amd64.whl", hash = "sha256:c344453ef3bf875a535b0488e3ad28e341adbd5a9ffb0f7d62cefacc8824ef2b"},
+ {file = "watchdog-4.0.2-py3-none-win_ia64.whl", hash = "sha256:baececaa8edff42cd16558a639a9b0ddf425f93d892e8392a56bf904f5eff22c"},
+ {file = "watchdog-4.0.2.tar.gz", hash = "sha256:b4dfbb6c49221be4535623ea4474a4d6ee0a9cef4a80b20c28db4d858b64e270"},
]
[package.extras]
@@ -4922,13 +4992,13 @@ files = [
[[package]]
name = "webcolors"
-version = "24.6.0"
+version = "24.8.0"
description = "A library for working with the color formats defined by HTML and CSS."
optional = false
python-versions = ">=3.8"
files = [
- {file = "webcolors-24.6.0-py3-none-any.whl", hash = "sha256:8cf5bc7e28defd1d48b9e83d5fc30741328305a8195c29a8e668fa45586568a1"},
- {file = "webcolors-24.6.0.tar.gz", hash = "sha256:1d160d1de46b3e81e58d0a280d0c78b467dc80f47294b91b1ad8029d2cedb55b"},
+ {file = "webcolors-24.8.0-py3-none-any.whl", hash = "sha256:fc4c3b59358ada164552084a8ebee637c221e4059267d0f8325b3b560f6c7f0a"},
+ {file = "webcolors-24.8.0.tar.gz", hash = "sha256:08b07af286a01bcd30d583a7acadf629583d1f79bfef27dd2c2c5c263817277d"},
]
[package.extras]
@@ -5003,13 +5073,13 @@ dev = ["Sphinx (>=4.5.0)", "black (>=22.3.0)", "pylint (>=2.13.7)", "pytest (>=7
[[package]]
name = "wheel"
-version = "0.43.0"
+version = "0.44.0"
description = "A built-package format for Python"
optional = false
python-versions = ">=3.8"
files = [
- {file = "wheel-0.43.0-py3-none-any.whl", hash = "sha256:55c570405f142630c6b9f72fe09d9b67cf1477fcf543ae5b8dcb1f5b7377da81"},
- {file = "wheel-0.43.0.tar.gz", hash = "sha256:465ef92c69fa5c5da2d1cf8ac40559a8c940886afcef87dcf14b9470862f1d85"},
+ {file = "wheel-0.44.0-py3-none-any.whl", hash = "sha256:2376a90c98cc337d18623527a97c31797bd02bad0033d41547043a1cbfbe448f"},
+ {file = "wheel-0.44.0.tar.gz", hash = "sha256:a29c3f2817e95ab89aa4660681ad547c0e9547f20e75b0562fe7723c9a2a9d49"},
]
[package.extras]
@@ -5097,4 +5167,4 @@ files = [
[metadata]
lock-version = "2.0"
python-versions = ">=3.11,<3.13"
-content-hash = "38cf29eb34bbf08bcdc545de3a8d8febbe64a52a94127d871820b8e3ecbf33ec"
+content-hash = "1a3b60841bfb644cf9426a21237665cca6872a7a0963d7b2f8dcd755dc0ae3ed"
diff --git a/pyproject.toml b/pyproject.toml
index bcd13bb0..b26f8618 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -58,7 +58,7 @@ orjson = "^3.9.13"
physiokit = "^0.8.1"
requests = "^2.31.0"
argdantic = {extras = ["all"], version = "^1.0.0"}
-neuralspot-edge = "^0.1.3"
+neuralspot-edge = {git = "https://github.com/AmbiqAI/neuralspot-edge.git"}
[tool.poetry.group.dev.dependencies]
ipython = "^8.21.0"