Add SDK example notebooks + link them from the model card

#2
README.md CHANGED
@@ -47,6 +47,19 @@ CHARM is served behind an API and consumed through the open-source [`c3-charm`](
47
 
48
  > **Dual-model serving.** A CHARM server can be backed by two independent checkpoints — one for embeddings (`/predict`, `client.embeddings`) and a separate one for forecasting (`/forecast`, `client.prediction`). They may differ in architecture, patch size, and embedding dimension, so read per-model properties from `client.model_info()` rather than hardcoding.
49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  ### Installation
51
 
52
  ```bash
 
47
 
48
  > **Dual-model serving.** A CHARM server can be backed by two independent checkpoints — one for embeddings (`/predict`, `client.embeddings`) and a separate one for forecasting (`/forecast`, `client.prediction`). They may differ in architecture, patch size, and embedding dimension, so read per-model properties from `client.model_info()` rather than hardcoding.
49
 
50
+ ### Example notebooks
51
+
52
+ Runnable notebooks live in [`notebooks/`](./notebooks) in this repo. Open them on the Hub, or download the folder and run locally after `pip install c3-charm[toolkit]` (set `CHARM_BASE_URL` / `CHARM_API_KEY` first). The classification and reconstruction/forecasting demos read the small sample datasets in [`notebooks/data/`](./notebooks/data).
53
+
54
+ | Notebook | What it covers |
55
+ |---|---|
56
+ | [`getting_started.ipynb`](./notebooks/getting_started.ipynb) | Client setup, first embeddings call, inspecting `model_info()`. |
57
+ | [`charm_toolkit_demo.ipynb`](./notebooks/charm_toolkit_demo.ipynb) | Tour of the `charm_toolkit` — datasets, precompute, trainer, and each task head. |
58
+ | [`demo_forecasting.ipynb`](./notebooks/demo_forecasting.ipynb) | Zero-shot quantile forecasting and the embedding-based `ForecastingModel` head (weather data). |
59
+ | [`demo_reconstruction.ipynb`](./notebooks/demo_reconstruction.ipynb) | Backcast reconstruction and the `ReconstructionModel` head for anomaly detection (weather data). |
60
+ | [`demo_classification.ipynb`](./notebooks/demo_classification.ipynb) | Time-series classification with the `ClassificationModel` head (BasicMotions data). |
61
+ | [`demo_retrieval_anomaly_detection.ipynb`](./notebooks/demo_retrieval_anomaly_detection.ipynb) | Embedding retrieval + kNN / zero-shot anomaly-detection recipes. |
62
+
63
  ### Installation
64
 
65
  ```bash
notebooks/charm_toolkit_demo.ipynb ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "\n",
8
+ "\n",
9
+ "# Using the Time Series Dataset Utilities\n"
10
+ ]
11
+ },
12
+ {
13
+ "cell_type": "markdown",
14
+ "metadata": {},
15
+ "source": [
16
+ "## Setup"
17
+ ]
18
+ },
19
+ {
20
+ "cell_type": "code",
21
+ "execution_count": null,
22
+ "metadata": {},
23
+ "outputs": [],
24
+ "source": [
25
+ "import torch\n",
26
+ "import numpy as np\n",
27
+ "from sklearn.preprocessing import StandardScaler\n",
28
+ "import matplotlib.pylab as plt\n",
29
+ "\n",
30
+ "from charm_toolkit.Data import Data\n",
31
+ "from charm_toolkit.BaseTimesSeries import (\n",
32
+ " UnwindowedDataset,\n",
33
+ " PreWindowedDataset,\n",
34
+ " LazyWindowedDataset,\n",
35
+ " ForecastingDataset,\n",
36
+ " train_test_split,\n",
37
+ ")\n"
38
+ ]
39
+ },
40
+ {
41
+ "cell_type": "markdown",
42
+ "metadata": {},
43
+ "source": [
44
+ "### Create an UnwindowedDataset\n",
45
+ "\n",
46
+ "We can use the `UnwindowedDataset` class as a simple container for **raw, unwindowed time series** of shape\n",
47
+ "`(T, C)`, where `T` is the number of time steps and `C` is the number of channels. Key features of this class\n",
48
+ "\n",
49
+ "- Stores the full time series in `self.data` as a `torch.Tensor` of shape `(T, C)`.\n",
50
+ "- Optionally applies a provided `StandardScaler` at construction time; otherwise it keeps\n",
51
+ " the data as-is (but you can later call `apply_scaler` from `BaseTimeSeriesDataset`).\n",
52
+ "- `__len__()` returns the **number of time steps** `T`.\n",
53
+ "- **Does not support direct indexing** via `__getitem__` (it raises an error instead).\n",
54
+ " Instead, it is meant to be converted into task-specific datasets:\n",
55
+ " - `.window(window_size, stride)` → returns a `LazyWindowedDataset` that creates\n",
56
+ " sliding windows on-the-fly.\n",
57
+ " - `.forecasting(train_horizon, test_horizon)` → returns a `ForecastingDataset`\n",
58
+ " that produces (input, target) pairs for forecasting."
59
+ ]
60
+ },
61
+ {
62
+ "cell_type": "code",
63
+ "execution_count": null,
64
+ "metadata": {},
65
+ "outputs": [],
66
+ "source": [
67
+ "T, C = 100, 3\n",
68
+ "data = np.random.randn(T, C).astype(np.float32)\n",
69
+ "descriptions = [\"temperature\", \"pressure\", \"vibration\"]\n",
70
+ "\n",
71
+ "unwindowed_ds = UnwindowedDataset(\n",
72
+ " data=data,\n",
73
+ " dataset_name=\"demo_unwindowed\",\n",
74
+ " descriptions=descriptions\n",
75
+ ")\n",
76
+ "\n",
77
+ "print(\"Raw dataset shape:\", unwindowed_ds.data.shape)\n"
78
+ ]
79
+ },
80
+ {
81
+ "cell_type": "markdown",
82
+ "metadata": {},
83
+ "source": [
84
+ "### Scaling\n",
85
+ "You can now apply scaling before splitting to avoid leakage as below"
86
+ ]
87
+ },
88
+ {
89
+ "cell_type": "code",
90
+ "execution_count": null,
91
+ "metadata": {},
92
+ "outputs": [],
93
+ "source": [
94
+ "unwindowed_ds.apply_scaler()\n",
95
+ "print(\"First 5 scaled rows:\\n\", unwindowed_ds.data[:5])\n",
96
+ "\n",
97
+ "original = unwindowed_ds.inverse_transform(unwindowed_ds.data[:5])\n",
98
+ "print(\"Inverse transformed:\\n\", original)\n"
99
+ ]
100
+ },
101
+ {
102
+ "cell_type": "markdown",
103
+ "metadata": {},
104
+ "source": [
105
+ "### Train/Test Split\n",
106
+ "You can also split data sequentially or randomly for training, validation and testing.\n"
107
+ ]
108
+ },
109
+ {
110
+ "cell_type": "code",
111
+ "execution_count": null,
112
+ "metadata": {},
113
+ "outputs": [],
114
+ "source": [
115
+ "train_ds, test_ds = train_test_split(unwindowed_ds, train_ratio=0.8)\n",
116
+ "\n",
117
+ "print(\"Train length:\", len(train_ds))\n",
118
+ "print(\"Test length:\", len(test_ds))\n"
119
+ ]
120
+ },
121
+ {
122
+ "cell_type": "markdown",
123
+ "metadata": {},
124
+ "source": [
125
+ "\n",
126
+ "### Lazy-windowed Dataset\n",
127
+ "It is commonly needed to create windowed versions of a time series data in a sliding fashion. We can do so on the fly from an UnwindowedDataset. In order to do so in a memory efficient manner for long time series, we do this lazily as below."
128
+ ]
129
+ },
130
+ {
131
+ "cell_type": "code",
132
+ "execution_count": null,
133
+ "metadata": {},
134
+ "outputs": [],
135
+ "source": [
136
+ "window_size = 10\n",
137
+ "stride = 5\n",
138
+ "\n",
139
+ "lazy_win_train = train_ds.window(window_size=window_size, stride=stride)\n",
140
+ "\n",
141
+ "print(\"Number of windows:\", len(lazy_win_train))\n",
142
+ "\n",
143
+ "# Inspect a window\n",
144
+ "sample_window = lazy_win_train[0]\n",
145
+ "print(\"Window shape:\", sample_window['ts_array'].shape)\n",
146
+ "print(\"Descriptions:\", sample_window['descriptions'])\n"
147
+ ]
148
+ },
149
+ {
150
+ "cell_type": "markdown",
151
+ "metadata": {},
152
+ "source": [
153
+ "### Using All Together: Split → Window → Forecast\n",
154
+ "\n",
155
+ "We can now put everything together. Below is an example of this.\n"
156
+ ]
157
+ },
158
+ {
159
+ "cell_type": "code",
160
+ "execution_count": null,
161
+ "metadata": {},
162
+ "outputs": [],
163
+ "source": [
164
+ "# 1. Split\n",
165
+ "train_ds, test_ds = train_test_split(unwindowed_ds, train_ratio=0.7)\n",
166
+ "\n",
167
+ "# 2. Lazy windowing\n",
168
+ "lazy_train = train_ds.window(window_size=15, stride=5)\n",
169
+ "lazy_test = test_ds.window(window_size=15, stride=5)\n",
170
+ "\n",
171
+ "print(\"Lazy train windows:\", len(lazy_train))\n",
172
+ "print(\"Lazy test windows:\", len(lazy_test))\n",
173
+ "\n",
174
+ "# 3. Forecasting\n",
175
+ "forecast_train = ForecastingDataset(train_ds, train_horizon=15, test_horizon=5)\n",
176
+ "forecast_test = ForecastingDataset(test_ds, train_horizon=15, test_horizon=5)\n",
177
+ "\n",
178
+ "print(\"Forecast train pairs:\", len(forecast_train))\n",
179
+ "print(\"Forecast test pairs:\", len(forecast_test))\n",
180
+ "\n",
181
+ "\n",
182
+ "\n",
183
+ "# Take the first forecasting pair\n",
184
+ "x, y = forecast_test[0]\n",
185
+ "x_np = x['ts_array'][0].numpy()\n",
186
+ "y_np = y['ts_array'][0].numpy()\n",
187
+ "\n",
188
+ "time_input = np.arange(len(x_np))\n",
189
+ "time_target = np.arange(len(x_np), len(x_np) + len(y_np))\n",
190
+ "\n",
191
+ "plt.figure(figsize=(10,4))\n",
192
+ "\n",
193
+ "# Plot input sequence\n",
194
+ "for i, desc in enumerate(x['descriptions']):\n",
195
+ " plt.plot(time_input, x_np[:, i], label=f\"{desc} (input)\", linestyle='-')\n",
196
+ "\n",
197
+ "# Plot target sequence\n",
198
+ "for i, desc in enumerate(y['descriptions']):\n",
199
+ " plt.plot(time_target, y_np[:, i], label=f\"{desc} (target)\", linestyle='--')\n",
200
+ "\n",
201
+ "plt.title(\"Forecasting Example: Input vs Target\")\n",
202
+ "plt.xlabel(\"Time Step\")\n",
203
+ "plt.ylabel(\"Value\")\n",
204
+ "plt.legend()\n",
205
+ "plt.show()\n"
206
+ ]
207
+ },
208
+ {
209
+ "cell_type": "code",
210
+ "execution_count": null,
211
+ "metadata": {},
212
+ "outputs": [],
213
+ "source": []
214
+ }
215
+ ],
216
+ "metadata": {
217
+ "kernelspec": {
218
+ "display_name": "charm",
219
+ "language": "python",
220
+ "name": "charm"
221
+ },
222
+ "language_info": {
223
+ "codemirror_mode": {
224
+ "name": "ipython",
225
+ "version": 3
226
+ },
227
+ "file_extension": ".py",
228
+ "mimetype": "text/x-python",
229
+ "name": "python",
230
+ "nbconvert_exporter": "python",
231
+ "pygments_lexer": "ipython3",
232
+ "version": "3.11.13"
233
+ }
234
+ },
235
+ "nbformat": 4,
236
+ "nbformat_minor": 4
237
+ }
notebooks/data/basicmotions.csv ADDED
The diff for this file is too large to render. See raw diff
 
notebooks/data/weather.csv ADDED
The diff for this file is too large to render. See raw diff
 
notebooks/demo_classification.ipynb ADDED
@@ -0,0 +1,507 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "id": "62b712f6-5a23-4de9-af1d-9cb94191f111",
6
+ "metadata": {},
7
+ "source": [
8
+ "# Time Series Classsification with Charm Embeddings\n",
9
+ "\n",
10
+ "This notebook walks through a full example of **time-series Classsification** using:\n",
11
+ "\n",
12
+ "- A synthetic multi-channel sinewave dataset \n",
13
+ "- Your **CharmClient** for embeddings \n",
14
+ "- Your custom **ClasssificationModel** \n",
15
+ "- A clean dataset pipeline with sliding windows \n",
16
+ "- The training loop powered by your custom **Trainer** \n",
17
+ "- A visualization callback that plots classsification during training \n",
18
+ "\n",
19
+ "The goal is pedagogical: understand *every single component* of the library.\n"
20
+ ]
21
+ },
22
+ {
23
+ "cell_type": "markdown",
24
+ "id": "97f74300-819e-4a66-8fe7-048c4fe8976a",
25
+ "metadata": {
26
+ "jp-MarkdownHeadingCollapsed": true
27
+ },
28
+ "source": [
29
+ "## Setup: Imports & Charm Client"
30
+ ]
31
+ },
32
+ {
33
+ "cell_type": "code",
34
+ "execution_count": null,
35
+ "id": "6c96e1cb-2d56-487c-af33-9fb7eef2d7cf",
36
+ "metadata": {},
37
+ "outputs": [],
38
+ "source": [
39
+ "from charm import CharmClient\n",
40
+ "from dotenv import load_dotenv\n",
41
+ "import os\n",
42
+ "import numpy as np\n",
43
+ "import pandas as pd\n",
44
+ "import torch\n",
45
+ "from torch.utils.data import DataLoader\n",
46
+ "import matplotlib.pyplot as plt\n",
47
+ "\n",
48
+ "# # Load environment variables from .env file\n",
49
+ "# load_dotenv()\n",
50
+ "\n",
51
+ "# # Get API key and base URL from environment variables\n",
52
+ "# api_key = os.getenv(\"CHARM_API_KEY\", \"sk-charm-default-key\")\n",
53
+ "# base_url = os.getenv(\"CHARM_BASE_URL\", \"EMBEDDING_MODEL_ENDPOINT\")\n",
54
+ "\n",
55
+ "api_key = \"sk-charm-default-key\"\n",
56
+ "base_url = \"EMBEDDING_MODEL_ENDPOINT\"\n",
57
+ "\n",
58
+ "# Create a client for embedding extraction\n",
59
+ "client = CharmClient(\n",
60
+ " base_url=base_url,\n",
61
+ " api_key=api_key,\n",
62
+ " timeout=60,\n",
63
+ " max_retries=3\n",
64
+ ")"
65
+ ]
66
+ },
67
+ {
68
+ "cell_type": "markdown",
69
+ "id": "92b27303-c19d-4d9a-9cbb-180832c97171",
70
+ "metadata": {
71
+ "jp-MarkdownHeadingCollapsed": true
72
+ },
73
+ "source": [
74
+ "## Dataset: Basic Motions\n",
75
+ "\n",
76
+ "For this tutorial notebook, we use the `Basic Motions` dataset. We have seeded this dataset under the `data` forlder. Below we load and process this dataset."
77
+ ]
78
+ },
79
+ {
80
+ "cell_type": "code",
81
+ "execution_count": null,
82
+ "id": "3f988b4a-8fc8-4e5c-a3b2-c0241909af8f",
83
+ "metadata": {},
84
+ "outputs": [],
85
+ "source": [
86
+ "df = pd.read_csv(\"data/basicmotions.csv\")\n",
87
+ "\n",
88
+ "# channel names\n",
89
+ "channel_names = df.columns[2:]\n",
90
+ "\n",
91
+ "# get raw numeric data\n",
92
+ "data_np = df[channel_names].values\n",
93
+ "\n",
94
+ "# convert to torc\n",
95
+ "tdata = torch.tensor(data_np)\n",
96
+ "\n",
97
+ "# reconstruct (B, T, C)\n",
98
+ "B = df[\"sample_id\"].nunique()\n",
99
+ "T = df[df[\"sample_id\"] == 0].shape[0] # time steps per sample\n",
100
+ "C = len(channel_names)\n",
101
+ "\n",
102
+ "\n",
103
+ "data = tdata.reshape(B, T, C)\n",
104
+ "\n",
105
+ "labels = df.drop_duplicates(\"sample_id\").sort_values(\"sample_id\")[\"label\"].tolist()\n",
106
+ "\n"
107
+ ]
108
+ },
109
+ {
110
+ "cell_type": "code",
111
+ "execution_count": null,
112
+ "id": "8c6a76d2-dda8-44e8-9688-5a9da10f455c",
113
+ "metadata": {},
114
+ "outputs": [],
115
+ "source": [
116
+ "\n",
117
+ "# We will have to convert the labels to IDs \n",
118
+ "\n",
119
+ "# Create mapping\n",
120
+ "label_to_id = {label: i for i, label in enumerate(sorted(set(labels)))}\n",
121
+ "\n",
122
+ "# Convert\n",
123
+ "labels = [label_to_id[l] for l in labels]"
124
+ ]
125
+ },
126
+ {
127
+ "cell_type": "markdown",
128
+ "id": "c4b88dd3-6281-4988-a657-ef0509640027",
129
+ "metadata": {
130
+ "jp-MarkdownHeadingCollapsed": true
131
+ },
132
+ "source": [
133
+ "## Classifier Dataset Splits\n",
134
+ "\n",
135
+ "To prepare our dataset for training, validation and testing, we use the function `create_classification_datasets`. This function prepares train/validation/test splits for time-series\n",
136
+ "classification tasks. It assumes each time series has a single label. The function:\n",
137
+ "\n",
138
+ "1. **Wraps the raw data and labels** \n",
139
+ " Creates a `PreWindowedDataset` where each sample is a full time series plus its\n",
140
+ " channel descriptions and classification label.\n",
141
+ "\n",
142
+ "2. **Splits the dataset** \n",
143
+ " Divides the data into train/val/test sets using either sequential or random splitting (here random)\n",
144
+ " (`train_ratio`, `val_ratio`).\n",
145
+ "\n",
146
+ "3. **Builds classification-ready datasets** \n",
147
+ " Wraps each split in a `ClassificationDataset`, ensuring the model receives data in the\n",
148
+ " correct structure expected by the `ClassificationModel`.\n",
149
+ "\n",
150
+ "4. **Returns the three dataset splits** \n",
151
+ " Ready to be used with PyTorch `DataLoader`s."
152
+ ]
153
+ },
154
+ {
155
+ "cell_type": "code",
156
+ "execution_count": null,
157
+ "id": "cbc47cb0-7e23-4789-ab41-6fccc50e1e05",
158
+ "metadata": {},
159
+ "outputs": [],
160
+ "source": [
161
+ "from charm_toolkit.Datasets import create_classification_datasets, collator\n",
162
+ "\n",
163
+ "train_ds, val_ds, test_ds = create_classification_datasets(\n",
164
+ " raw_data=data, # [B, T, C] tensor; here [80, 100, 6]\n",
165
+ " labels=labels, # List of labels, one per time series.\n",
166
+ " descriptions=list(channel_names), # list of channel names, e.g., [\"ch0\", \"ch1\"]\n",
167
+ " \n",
168
+ " train_ratio=0.7, # fraction of data used for training (0.7 → 70%)\n",
169
+ " val_ratio=0.15, # fraction of data used for validation (0.15 → 15%)\n",
170
+ " sequential=False, # if True, splits are sequential, not shuffled\n",
171
+ ")\n",
172
+ "\n",
173
+ "\n",
174
+ "train_loader = DataLoader(train_ds, batch_size=32, collate_fn=collator, num_workers=1)\n",
175
+ "val_loader = DataLoader(val_ds, batch_size=32, collate_fn=collator, num_workers=1)"
176
+ ]
177
+ },
178
+ {
179
+ "cell_type": "markdown",
180
+ "id": "e48673a2-3f4d-4336-b562-9e1f433378ee",
181
+ "metadata": {
182
+ "jp-MarkdownHeadingCollapsed": true
183
+ },
184
+ "source": [
185
+ "## Instantiating the Model\n",
186
+ "\n",
187
+ "We use the `ClassificationModel` class to build a classifier based on the CHARM embeddings. Given raw time series (`ts_array` + `descriptions`), it\n",
188
+ "first obtains embeddings of shape **[B, T_p, C, D]**, where:\n",
189
+ "\n",
190
+ "- **T_p** — number of temporal patches \n",
191
+ "- **C** — channels \n",
192
+ "- **D** — embedding dimension \n",
193
+ "\n",
194
+ "The model then applies **two stages of pooling** to convert these embeddings into a fixed-size\n",
195
+ "feature vector:\n",
196
+ "\n",
197
+ "#### Temporal pooling (`pooling_over_t`)\n",
198
+ "- **\"mean\"** – average over temporal patches \n",
199
+ "- **\"last\"** – use the final temporal patch \n",
200
+ "- **\"flatten\"** – keep all patches (most expressive)\n",
201
+ "\n",
202
+ "#### Channel pooling (`pooling_over_channels`)\n",
203
+ "- **\"mean\"** – average across channels \n",
204
+ "- **\"flatten\"** – keep all channels\n",
205
+ "\n",
206
+ "These pooling options produce feature vectors ranging from compact (`[B, D]`) to highly\n",
207
+ "expressive (`[B, T_p × C × D]`), depending on how much structure you want to retain.\n",
208
+ "\n",
209
+ "The pooled vector is passed through a **classifier head**, chosen via `classifier_type`:\n",
210
+ "- **\"linear\"** – single fully connected layer \n",
211
+ "- **\"mlp\"** – 2-layer MLP with GELU + dropout \n",
212
+ "- **\"svm\"** – differentiable SVM-like head (linear or RBF kernel) \n",
213
+ "- or a custom `nn.Module`\n",
214
+ "\n",
215
+ "The model outputs **logits of shape `[B, num_classes]`**."
216
+ ]
217
+ },
218
+ {
219
+ "cell_type": "code",
220
+ "execution_count": null,
221
+ "id": "fa09e6b6-75b2-467a-9746-6d2c13df8585",
222
+ "metadata": {},
223
+ "outputs": [],
224
+ "source": [
225
+ "from charm_toolkit.models import ClassificationModel\n",
226
+ "\n",
227
+ "model = ClassificationModel(\n",
228
+ " embedding_client=client, \n",
229
+ " num_classes=len(set(labels)),\n",
230
+ " # Number of classes to predict.\n",
231
+ " \n",
232
+ " hidden_dim=128, \n",
233
+ " # Used only for MLP-style classifier.\n",
234
+ " # Controls width of intermediate layers.\n",
235
+ "\n",
236
+ " dropout=0.1, \n",
237
+ " # Dropout applied\n",
238
+ "\n",
239
+ " pooling_over_t= \"mean\",\n",
240
+ " # Pooling mode for temporal dimension (\"flatten\", \"mean\", \"last\")\n",
241
+ "\n",
242
+ " pooling_over_channels=\"mean\",\n",
243
+ " # Pooling mode for channel dimension (\"flatten\", \"mean\")\n",
244
+ "\n",
245
+ " classifier_type=\"mlp\",\n",
246
+ " # Type of classifier to use (\"linear\", \"mlp\", \"svm\") or a custom module.\n",
247
+ " # Default MLP\n",
248
+ "\n",
249
+ " kernel=None,\n",
250
+ " # Optional: Kernel type for SVM classifier. \"linear\" and \"rbf\" supported.\n",
251
+ " \n",
252
+ " gamma =0.1,\n",
253
+ " # Gamma parameter for RBF kernel in SVM\n",
254
+ " \n",
255
+ " T=None,\n",
256
+ " # Expected temporal dimension (required if pooling_over_t=\"flatten\")\n",
257
+ "\n",
258
+ " num_channels=None,\n",
259
+ " # Number of channels (required if pooling_over_channels=\"flatten\")\n",
260
+ ")\n"
261
+ ]
262
+ },
263
+ {
264
+ "cell_type": "markdown",
265
+ "id": "3cc41797-cca5-4b35-97c4-aacfe6de2fd9",
266
+ "metadata": {
267
+ "jp-MarkdownHeadingCollapsed": true
268
+ },
269
+ "source": [
270
+ "## Setting Up the Trainer\n",
271
+ "\n",
272
+ "Having defined the model architecture, we can now train it. The `Trainer` class provides a lightweight, flexible training loop for PyTorch models,\n",
273
+ "including support for validation, early stopping, gradient accumulation, gradient clipping,\n",
274
+ "learning-rate scheduling, callbacks, and basic visualization snapshots.\n",
275
+ "\n",
276
+ "A `Trainer` instance is created with:\n",
277
+ "- a model (`nn.Module`)\n",
278
+ "- `train_loader`, and optionally `val_loader` / `test_loader`\n",
279
+ "- training hyperparameters such as learning rate, weight decay, epochs,\n",
280
+ " gradient accumulation steps, and early-stopping parameters.\n",
281
+ "\n",
282
+ "During training (`fit()`):\n",
283
+ "1. **Each epoch streams batches** from the `DataLoader` (no full-dataset loading).\n",
284
+ "2. For each batch, it:\n",
285
+ " - runs a forward pass\n",
286
+ " - computes the loss\n",
287
+ " - performs optional gradient accumulation (`accum_steps`)\n",
288
+ " - applies gradient clipping (`max_grad_norm`)\n",
289
+ " - updates the optimizer\n",
290
+ "3. After each epoch, it:\n",
291
+ " - evaluates on the validation set (if provided)\n",
292
+ " - steps the scheduler (e.g., `ReduceLROnPlateau`)\n",
293
+ " - triggers any user-defined callbacks\n",
294
+ " - checks early-stopping conditions (`patience`, `min_delta`)\n",
295
+ "4. The best model (lowest validation loss) is stored and restored at the end.\n",
296
+ "\n",
297
+ "During validation, the trainer also stores a small number of snapshots (targets, predictions, inputs) for visualization. The `evaluate()` method runs a full validation/testing pass and returns the average loss."
298
+ ]
299
+ },
300
+ {
301
+ "cell_type": "code",
302
+ "execution_count": null,
303
+ "id": "5721f474-5b7f-49cb-9df9-c97bf75f5645",
304
+ "metadata": {},
305
+ "outputs": [],
306
+ "source": [
307
+ "from charm_toolkit.Trainer import Trainer\n",
308
+ "from charm_toolkit.Plots import PlotCallback\n",
309
+ "\n",
310
+ "plot_cb = PlotCallback(\n",
311
+ " every=10, \n",
312
+ " # Plot every N steps\n",
313
+ "\n",
314
+ " show_class=True, \n",
315
+ " # Plot original vs classified\n",
316
+ "\n",
317
+ " n_samples=3, \n",
318
+ " # number of samples to plot\n",
319
+ "\n",
320
+ " channels=[0,1], \n",
321
+ " # int or list of channels to visualize\n",
322
+ ")\n",
323
+ "\n",
324
+ "\n",
325
+ "trainer = Trainer(\n",
326
+ " model=model,\n",
327
+ " train_loader=train_loader,\n",
328
+ " val_loader=val_loader,\n",
329
+ " test_loader=None, \n",
330
+ " # optional — only needed if you call trainer.test()\n",
331
+ "\n",
332
+ " lr=1e-3, \n",
333
+ " # learning rate. Default:1e-3\n",
334
+ "\n",
335
+ " weight_decay=1e-4, \n",
336
+ " # L2 regularization. Default:1e-4\n",
337
+ "\n",
338
+ " epochs=1000, \n",
339
+ " # maximum training epochs. Default:1000\n",
340
+ "\n",
341
+ " accum_steps=1, \n",
342
+ " # gradient accumulation:\n",
343
+ " # if >1: effectively increases batch size without increasing memory\n",
344
+ " # Default: 1\n",
345
+ "\n",
346
+ " patience=20, \n",
347
+ " # early stopping patience. Default: 5\n",
348
+ "\n",
349
+ " min_delta=1e-4, \n",
350
+ " # minimum improvement required for early stopping. Default: 1e-4\n",
351
+ "\n",
352
+ " max_grad_norm=5.0, \n",
353
+ " # gradient clipping for stability. Default: 5\n",
354
+ "\n",
355
+ " device=None, \n",
356
+ " # default: \"cuda\" if available else \"cpu\"\n",
357
+ "\n",
358
+ " save_dir=\"./classification_checkpoints\",\n",
359
+ " # where best checkpoints are saved\n",
360
+ "\n",
361
+ " callbacks=[plot_cb], \n",
362
+ " # supports ANY number of callbacks:\n",
363
+ " # - plotting\n",
364
+ " # - logging (wandb, tensorboard)\n",
365
+ " # - checkpointing\n",
366
+ " # - custom user hooks\n",
367
+ "\n",
368
+ " criterion=torch.nn.CrossEntropyLoss(), \n",
369
+ " # default: MSELoss\n",
370
+ " # user may pass:\n",
371
+ " # nn.L1Loss()\n",
372
+ " # nn.HuberLoss()\n",
373
+ " # custom callable loss\n",
374
+ "\n",
375
+ " optimizer=None, \n",
376
+ " # default: AdamW\n",
377
+ " # user may pass:\n",
378
+ " # torch.optim.SGD\n",
379
+ " # torch.optim.Adam\n",
380
+ " # any custom optimizer\n",
381
+ "\n",
382
+ " scheduler=None, \n",
383
+ " # default: ReduceLROnPlateau\n",
384
+ " # user may pass:\n",
385
+ " # StepLR\n",
386
+ " # OneCycleLR\n",
387
+ " # custom scheduler\n",
388
+ ")\n"
389
+ ]
390
+ },
391
+ {
392
+ "cell_type": "code",
393
+ "execution_count": null,
394
+ "id": "c708a47e-6175-42da-a783-5dccfdfb0b37",
395
+ "metadata": {},
396
+ "outputs": [],
397
+ "source": [
398
+ "# How training is triggered!\n",
399
+ "trainer.fit()\n"
400
+ ]
401
+ },
402
+ {
403
+ "cell_type": "markdown",
404
+ "id": "eeddcadd-3d3b-4dcf-b798-c428b9d30415",
405
+ "metadata": {
406
+ "jp-MarkdownHeadingCollapsed": true
407
+ },
408
+ "source": [
409
+ "## Using the Trained Model for Inference\n",
410
+ "With our trained PyTorch model, we can now perform inference on the test dataset and compute basic evaluation metrics to assess forecasting performance."
411
+ ]
412
+ },
413
+ {
414
+ "cell_type": "code",
415
+ "execution_count": null,
416
+ "id": "19d9b8fc-d26d-4290-9573-72b1c0289db4",
417
+ "metadata": {},
418
+ "outputs": [],
419
+ "source": [
420
+ "import seaborn as sns\n",
421
+ "from sklearn.metrics import confusion_matrix, classification_report\n",
422
+ "\n",
423
+ "# ---- Build class names from label_to_id dict ----\n",
424
+ "class_names = list(label_to_id.keys()) \n",
425
+ "num_classes = len(class_names)\n",
426
+ "test_dataloader=DataLoader(test_ds, batch_size=32, collate_fn=collator, num_workers=1)\n",
427
+ "\n",
428
+ " # ---- Collect predictions ----\n",
429
+ "model.eval()\n",
430
+ "\n",
431
+ "all_targets = []\n",
432
+ "all_preds = []\n",
433
+ "\n",
434
+ "with torch.no_grad():\n",
435
+ " for batch in test_dataloader:\n",
436
+ " x, y = batch[0], batch[1]\n",
437
+ " logits = model(x)\n",
438
+ "\n",
439
+ " # Convert logits → predicted class\n",
440
+ " pred = torch.argmax(logits, dim=1)\n",
441
+ " all_targets.append(y.cpu())\n",
442
+ " all_preds.append(pred.cpu())\n",
443
+ "\n",
444
+ "y_true = torch.cat(all_targets).numpy()\n",
445
+ "y_pred = torch.cat(all_preds).numpy()\n",
446
+ "\n",
447
+ " # ---- Metrics ----\n",
448
+ "cm = confusion_matrix(y_true, y_pred)\n",
449
+ "\n",
450
+ "report = classification_report(\n",
451
+ " y_true,\n",
452
+ " y_pred,\n",
453
+ " target_names=class_names,\n",
454
+ " zero_division=0 # avoid warnings if some class missing\n",
455
+ ")\n",
456
+ "print(\"\\n=== Classification Report ===\")\n",
457
+ "print(report)\n",
458
+ "\n",
459
+ "# ---- Plot confusion matrix ----\n",
460
+ "\n",
461
+ "plt.figure(figsize=(6, 6))\n",
462
+ "sns.heatmap(\n",
463
+ " cm,\n",
464
+ " annot=True,\n",
465
+ " fmt=\"d\",\n",
466
+ " cmap=\"Blues\",\n",
467
+ " xticklabels=class_names,\n",
468
+ " yticklabels=class_names\n",
469
+ ")\n",
470
+ "plt.title(\"Confusion Matrix\")\n",
471
+ "plt.xlabel(\"Predicted\")\n",
472
+ "plt.ylabel(\"True\")\n",
473
+ "plt.tight_layout()\n",
474
+ "plt.show()\n"
475
+ ]
476
+ },
477
+ {
478
+ "cell_type": "code",
479
+ "execution_count": null,
480
+ "id": "fa86a188-0924-4766-8840-dd2bfca2dee6",
481
+ "metadata": {},
482
+ "outputs": [],
483
+ "source": []
484
+ }
485
+ ],
486
+ "metadata": {
487
+ "kernelspec": {
488
+ "display_name": "charm",
489
+ "language": "python",
490
+ "name": "charm"
491
+ },
492
+ "language_info": {
493
+ "codemirror_mode": {
494
+ "name": "ipython",
495
+ "version": 3
496
+ },
497
+ "file_extension": ".py",
498
+ "mimetype": "text/x-python",
499
+ "name": "python",
500
+ "nbconvert_exporter": "python",
501
+ "pygments_lexer": "ipython3",
502
+ "version": "3.11.13"
503
+ }
504
+ },
505
+ "nbformat": 4,
506
+ "nbformat_minor": 5
507
+ }
notebooks/demo_forecasting.ipynb ADDED
@@ -0,0 +1,771 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "id": "62b712f6-5a23-4de9-af1d-9cb94191f111",
6
+ "metadata": {},
7
+ "source": [
8
+ "# Time Series Forecasting with Charm Embeddings\n",
9
+ "\n",
10
+ "This notebook walks through a full example of **time-series Forecasting** using:\n",
11
+ "\n",
12
+ "- A synthetic multi-channel sinewave dataset \n",
13
+ "- Your **CharmClient** for embeddings \n",
14
+ "- Your custom **ForecastingModel** \n",
15
+ "- A clean dataset pipeline with sliding windows \n",
16
+ "- The training loop powered by your custom **Trainer** \n",
17
+ "- A visualization callback that plots forecasting during training \n",
18
+ "\n",
19
+ "The goal is pedagogical: understand *every single component* of the library.\n"
20
+ ]
21
+ },
22
+ {
23
+ "cell_type": "markdown",
24
+ "id": "97f74300-819e-4a66-8fe7-048c4fe8976a",
25
+ "metadata": {
26
+ "jp-MarkdownHeadingCollapsed": true
27
+ },
28
+ "source": [
29
+ "### Setup: Imports & Charm Client"
30
+ ]
31
+ },
32
+ {
33
+ "cell_type": "code",
34
+ "execution_count": null,
35
+ "id": "6c96e1cb-2d56-487c-af33-9fb7eef2d7cf",
36
+ "metadata": {},
37
+ "outputs": [],
38
+ "source": [
39
+ "from charm import CharmClient\n",
40
+ "from dotenv import load_dotenv\n",
41
+ "import os\n",
42
+ "import numpy as np\n",
43
+ "import torch\n",
44
+ "from torch.utils.data import DataLoader\n",
45
+ "import matplotlib.pyplot as plt\n",
46
+ "import torch.nn as nn\n",
47
+ "import numpy as np\n",
48
+ "\n",
49
+ "# # Load environment variables from .env file\n",
50
+ "# load_dotenv()\n",
51
+ "\n",
52
+ "# # Get API key and base URL from environment variables\n",
53
+ "# api_key = os.getenv(\"CHARM_API_KEY\", \"sk-charm-default-key\")\n",
54
+ "# base_url = os.getenv(\"CHARM_BASE_URL\", \"EMBEDDING_MODEL_ENDPOINT\")\n",
55
+ "\n",
56
+ "api_key = \"sk-charm-default-key\"\n",
57
+ "base_url = \"EMBEDDING_MODEL_ENDPOINT\"\n",
58
+ "\n",
59
+ "# Create a client for embedding extraction\n",
60
+ "client = CharmClient(\n",
61
+ " base_url=base_url,\n",
62
+ " api_key=api_key,\n",
63
+ " timeout=60,\n",
64
+ " max_retries=3\n",
65
+ ")\n"
66
+ ]
67
+ },
68
+ {
69
+ "cell_type": "markdown",
70
+ "id": "92b27303-c19d-4d9a-9cbb-180832c97171",
71
+ "metadata": {
72
+ "jp-MarkdownHeadingCollapsed": true
73
+ },
74
+ "source": [
75
+ "## Dataset: Weather\n",
76
+ "\n",
77
+ "Here we rely on the `Weather` dataset. This dataset has been seeded in the `data` folder. Let's start by visualizing this data."
78
+ ]
79
+ },
80
+ {
81
+ "cell_type": "code",
82
+ "execution_count": null,
83
+ "id": "e08fa2e7-173a-4911-baae-bebd274af93d",
84
+ "metadata": {},
85
+ "outputs": [],
86
+ "source": [
87
+ "import pandas as pd\n",
88
+ "import torch\n",
89
+ "import numpy as np\n",
90
+ "import matplotlib.pyplot as plt\n",
91
+ "from itertools import islice\n",
92
+ "\n",
93
+ "# Load the dataset\n",
94
+ "path = \"data/weather.csv\"\n",
95
+ "df = pd.read_csv(path)\n",
96
+ "\n",
97
+ "# Extract data values (exclude time/index column)\n",
98
+ "data_np = df.iloc[:, 1:].values\n",
99
+ "\n",
100
+ "# Convert to PyTorch t\n",
101
+ "tdata = torch.tensor(data_np)\n",
102
+ "\n",
103
+ "# Get channel names\n",
104
+ "channel_names = list(df.columns[1:])\n",
105
+ "\n",
106
+ "print(\"Channel names:\", channel_names)\n",
107
+ "print(\"Tensor shape:\", tdata.shape) \n",
108
+ "\n",
109
+ "# Plotting: separate y-axis for each channel\n",
110
+ "plt.style.use(\"default\")\n",
111
+ "num_channels = 5 # Number of channels to plot\n",
112
+ "colors = plt.cm.tab10(np.linspace(0, 1, num_channels))\n",
113
+ "\n",
114
+ "fig, axes = plt.subplots(num_channels, 1, figsize=(15, 3*num_channels), sharex=True)\n",
115
+ "\n",
116
+ "for i, (channel, color) in enumerate(islice(zip(channel_names, colors), num_channels)):\n",
117
+ " ax = axes[i]\n",
118
+ " ax.plot(data_np[:, i], label=channel, color=color, linewidth=1.4, alpha=0.9)\n",
119
+ " ax.set_ylabel(channel, fontsize=12)\n",
120
+ " ax.grid(True, linestyle=\"--\", alpha=0.3)\n",
121
+ " ax.legend(loc=\"upper right\", fontsize=10)\n",
122
+ "\n",
123
+ "axes[-1].set_xlabel(\"Time Index\", fontsize=12)\n",
124
+ "fig.suptitle(\"Weather Data (First 5 Channels)\", fontsize=16)\n",
125
+ "plt.tight_layout(rect=[0, 0, 1, 0.96])\n",
126
+ "plt.show()\n",
127
+ "\n"
128
+ ]
129
+ },
130
+ {
131
+ "cell_type": "markdown",
132
+ "id": "c4b88dd3-6281-4988-a657-ef0509640027",
133
+ "metadata": {
134
+ "jp-MarkdownHeadingCollapsed": true
135
+ },
136
+ "source": [
137
+ "## Sliding-Window Forecasting Dataset\n",
138
+ "\n",
139
+ "To prepare our dataset for training, validation and testing, we use the function `create_forecasting_datasets`. This function prepares train/validation/test splits for forecasting tasks.\n",
140
+ "It takes raw time series and produces windowed input–target pairs for supervised learning. The function\n",
141
+ "\n",
142
+ "1. **Wraps the raw data** \n",
143
+ " Creates a base dataset where each sample is a full time series instance along with its\n",
144
+ " channel descriptions.\n",
145
+ "\n",
146
+ "2. **Splits into train/val/test** \n",
147
+ " Uses sequential splitting based on `train_ratio` and `val_ratio`.\n",
148
+ "\n",
149
+ "3. **Applies windowing for forecasting** \n",
150
+ " Converts each split into a `ForecastingDataset`, where:\n",
151
+ " - the input is a window of fixed length (`input_size`)\n",
152
+ " - the target is the next `horizon` time steps.\n",
153
+ "\n",
154
+ "4. **Returns datasets ready for DataLoaders** \n",
155
+ " Each output dataset serves as input for training a `ForecastingModel`."
156
+ ]
157
+ },
158
+ {
159
+ "cell_type": "code",
160
+ "execution_count": null,
161
+ "id": "cbc47cb0-7e23-4789-ab41-6fccc50e1e05",
162
+ "metadata": {},
163
+ "outputs": [],
164
+ "source": [
165
+ "from charm_toolkit.Datasets import create_forecasting_datasets, collator\n",
166
+ "\n",
167
+ "train_ds, val_ds, test_ds = create_forecasting_datasets(\n",
168
+ " raw_data = tdata, # Tensor of shape [T, C], e.g. [4096, 3]\n",
169
+ " descriptions = channel_names, # List of channel names, e.g. [\"ch0\", \"ch1\"]\n",
170
+ "\n",
171
+ " train_horizon = 96, # Number of time steps used as model input\n",
172
+ " test_horizon = 96, # Number of time steps to forecast\n",
173
+ "\n",
174
+ " train_ratio = 0.70, # 70% of data for training\n",
175
+ " val_ratio = 0.15, # 15% of data for validation (rest → test)\n",
176
+ "\n",
177
+ " sequential = True, # Use sequential (not shuffled) splits\n",
178
+ " scale = True # Scale using training statistics\n",
179
+ " # Access via: train_ds.dataset.scaler\n",
180
+ ")\n"
181
+ ]
182
+ },
183
+ {
184
+ "cell_type": "markdown",
185
+ "id": "793532f6-874d-493c-838e-ffec6f25d6af",
186
+ "metadata": {
187
+ "jp-MarkdownHeadingCollapsed": true
188
+ },
189
+ "source": [
190
+ "## Zero-shot forecasting\n",
191
+ "\n",
192
+ "Zero-shot forecasting evaluates the model’s ability to predict future values **without any additional training or fine-tuning on the target series**. The model is given only a historical context window and the natural-language descriptions of each channel, and it must extrapolate the future based on the representations it has already learned.\n",
193
+ "\n",
194
+ "In this setting, we select a single example from the test set and provide:\n",
195
+ "\n",
196
+ "* the **context window** (`ts_array`), containing the past observations\n",
197
+ "* the **channel descriptions**, used by Charm to generate semantic embeddings\n",
198
+ "* a desired **forecast horizon** (`target_len`)\n",
199
+ "\n",
200
+ "The prediction is produced by calling the Charm prediction API. The model returns a **probabilistic forecast** in the form of multiple quantiles for each channel and time step. These predictions are automatically **denormalized**, allowing direct comparison with the ground-truth future values."
201
+ ]
202
+ },
203
+ {
204
+ "cell_type": "code",
205
+ "execution_count": null,
206
+ "id": "ff3d6080-49a2-4a2d-a905-c6811fef2a48",
207
+ "metadata": {},
208
+ "outputs": [],
209
+ "source": [
210
+ "idx = 10 # index of the sample to evaluate\n",
211
+ "target_len = 96 # number of future time steps to predict\n",
212
+ "\n",
213
+ "ts_array = test_ds[idx][0]['ts_array'].tolist() # input time-series\n",
214
+ "descriptions = test_ds[idx][0]['descriptions'] # descriptions\n",
215
+ "\n",
216
+ "response = client.prediction.create( # run model prediction\n",
217
+ " ts_array=ts_array,\n",
218
+ " descriptions=descriptions,\n",
219
+ " target_len=target_len,\n",
220
+ " return_tensors=\"np\"\n",
221
+ ")\n",
222
+ "\n",
223
+ "preds = response.denormalized_predictions.squeeze(0) # model output (denormalized)\n",
224
+ "true = test_ds[idx][1].squeeze(0) # ground-truth target values\n",
225
+ "context = test_ds[idx][0]['ts_array'].squeeze(0) # original input context\n"
226
+ ]
227
+ },
228
+ {
229
+ "cell_type": "code",
230
+ "execution_count": null,
231
+ "id": "6f1781ae-31b0-475a-a5dd-c04888852b3a",
232
+ "metadata": {},
233
+ "outputs": [],
234
+ "source": [
235
+ "# Configuration\n",
236
+ "n_channels = 3 # number of time-series channels to plot\n",
237
+ "T_ctx, C = context.shape \n",
238
+ "T, _, Q = preds.shape \n",
239
+ "\n",
240
+ "# Time axes\n",
241
+ "t_ctx = np.arange(T_ctx) \n",
242
+ "t_fut = np.arange(T_ctx, T_ctx + T) \n",
243
+ "\n",
244
+ "# Quantile indices\n",
245
+ "q_low, q_high = 1, 19 # lower/upper quantiles (e.g. 10th and 90th)\n",
246
+ "q_med = Q // 2 # median quantile index\n",
247
+ "\n",
248
+ "# Create subplots (one per channel)\n",
249
+ "fig, axes = plt.subplots(n_channels, 1, figsize=(14, 3 * n_channels), sharex=True)\n",
250
+ "if n_channels == 1:\n",
251
+ " axes = [axes]\n",
252
+ "\n",
253
+ "for ch in range(n_channels):\n",
254
+ " ax = axes[ch]\n",
255
+ "\n",
256
+ " # 1. Plot context (historical input)\n",
257
+ " ax.plot(t_ctx, context[:, ch], color=\"black\", lw=2, label=\"Context\")\n",
258
+ "\n",
259
+ " # 2. Plot quantile uncertainty fan\n",
260
+ " for i in range(q_low, q_med):\n",
261
+ " # Alpha increases as quantiles approach the median\n",
262
+ " alpha_factor = (i - q_low) / (q_med - q_low)\n",
263
+ " alpha = 0.05 + 0.20 * alpha_factor\n",
264
+ "\n",
265
+ " # Shade area between symmetric quantiles (e.g., 10th–90th)\n",
266
+ " ax.fill_between(\n",
267
+ " t_fut,\n",
268
+ " preds[:, ch, i],\n",
269
+ " preds[:, ch, Q - 1 - i],\n",
270
+ " color=\"red\",\n",
271
+ " alpha=0.1, # low alpha; overlap creates gradient effect\n",
272
+ " lw=0\n",
273
+ " )\n",
274
+ "\n",
275
+ " # 3. Plot median prediction, ground truth, and context divider\n",
276
+ " ax.plot(t_fut, preds[:, ch, q_med], color=\"red\", ls=\"--\", lw=2, label=\"Median Pred\")\n",
277
+ " ax.plot(t_fut, true[:, ch], color=\"green\", lw=2, label=\"Ground Truth\")\n",
278
+ " ax.axvline(T_ctx - 1, color=\"gray\", ls=\":\", alpha=0.5) # end of context\n",
279
+ "\n",
280
+ " ax.set_title(f\"Channel {ch}\")\n",
281
+ " if ch == 0:\n",
282
+ " ax.legend(loc=\"upper left\")\n",
283
+ "\n",
284
+ "# Final plot formatting\n",
285
+ "axes[-1].set_xlabel(\"Time\")\n",
286
+ "plt.tight_layout()\n",
287
+ "plt.show()\n"
288
+ ]
289
+ },
290
+ {
291
+ "cell_type": "markdown",
292
+ "id": "5f83fd48-3039-451d-8e1b-b81ee7a7f1c0",
293
+ "metadata": {},
294
+ "source": [
295
+ "## Moving on...\n",
296
+ "While zero-shot forecasting highlights the model’s ability to generalize without adaptation, it is only one possible usage mode. In many practical settings, better performance can be achieved by linear probing, where the Charm embeddings are kept frozen and a lightweight forecasting head is trained on task-specific data. This allows the model to retain its general semantic understanding while efficiently adapting to the statistical structure of a particular dataset."
297
+ ]
298
+ },
299
+ {
300
+ "cell_type": "markdown",
301
+ "id": "7e1733b6-f84e-4f82-aba2-0884dd8ecb7e",
302
+ "metadata": {
303
+ "jp-MarkdownHeadingCollapsed": true
304
+ },
305
+ "source": [
306
+ "## Precomputing Embeddings\n",
307
+ "\n",
308
+ "It is possible to precompute the embeddings and save them into disk. This will make training faster and allow us to train different models or heads and experiment **without** having to compute the embeddings again! To this end we use the function `precompute_dataset_embeddings` that computes embeddings for an entire dataset **once** and stores them efficiently in a memory-mapped file on disk. The function works in three steps\n",
309
+ "\n",
310
+ "1. **Infer embedding shape** \n",
311
+ " It embeds a small initial batch to determine the full output shape and initializes a\n",
312
+ " `memmap` file on disk to store all embeddings.\n",
313
+ "\n",
314
+ "2. **Process dataset in batches to avoid memory issues** \n",
315
+ " It iterates through the dataset in chunks (`memory_batch_size`), computes embeddings\n",
316
+ " asynchronously using the model’s embedding client, and writes each batch directly into\n",
317
+ " the correct slice of the memmap file.\n",
318
+ "\n",
319
+ "3. **Flush and return shape** \n",
320
+ " Each batch is saved immediately, and the function returns the full embedding shape for\n",
321
+ " later use."
322
+ ]
323
+ },
324
+ {
325
+ "cell_type": "code",
326
+ "execution_count": null,
327
+ "id": "f22c2b26-9810-4076-97e2-1e315cb5341b",
328
+ "metadata": {
329
+ "jupyter": {
330
+ "source_hidden": true
331
+ }
332
+ },
333
+ "outputs": [],
334
+ "source": [
335
+ "from charm_toolkit.EmbeddingUtils import precompute_dataset_embeddings, PrecomputedEmbeddingsDataset\n",
336
+ "\n",
337
+ "# Precompute embeddings\n",
338
+ "emb_shape_train = precompute_dataset_embeddings(\n",
339
+ " client=client,\n",
340
+ " dataset=train_ds,\n",
341
+ " output_path=\"./outputs/train_embeddings.pt\"\n",
342
+ ")\n",
343
+ "emb_shape_val = precompute_dataset_embeddings(\n",
344
+ " client=client,\n",
345
+ " dataset=val_ds,\n",
346
+ " output_path=\"./outputs/val_embeddings.pt\"\n",
347
+ ")\n",
348
+ "emb_shape_test = precompute_dataset_embeddings(\n",
349
+ " client=client,\n",
350
+ " dataset=test_ds,\n",
351
+ " output_path=\"./outputs/test_embeddings.pt\"\n",
352
+ ")\n",
353
+ "\n",
354
+ "\n",
355
+ "# # # Create datasets with precomputed embeddings\n",
356
+ "train_dataset_with_embeddings = PrecomputedEmbeddingsDataset(\n",
357
+ " train_ds,\n",
358
+ " \"./outputs/train_embeddings.pt\",\n",
359
+ " emb_shape_train\n",
360
+ ")\n",
361
+ "\n",
362
+ "val_dataset_with_embeddings = PrecomputedEmbeddingsDataset(\n",
363
+ " val_ds,\n",
364
+ " \"./outputs/val_embeddings.pt\",\n",
365
+ " emb_shape_val\n",
366
+ ")\n",
367
+ "test_dataset_with_embeddings = PrecomputedEmbeddingsDataset(\n",
368
+ " test_ds,\n",
369
+ " \"./outputs/test_embeddings.pt\",\n",
370
+ " emb_shape_test\n",
371
+ ")\n",
372
+ "\n",
373
+ "# Create dataloaders\n",
374
+ "train_loader = DataLoader(train_dataset_with_embeddings, batch_size=512, shuffle=True, num_workers=8,collate_fn=collator)\n",
375
+ "val_loader = DataLoader(val_dataset_with_embeddings, batch_size=512, shuffle=False, num_workers=8,collate_fn=collator)\n",
376
+ "test_loader = DataLoader(test_dataset_with_embeddings, batch_size=512, shuffle=False, num_workers=8,collate_fn=collator)\n"
377
+ ]
378
+ },
379
+ {
380
+ "cell_type": "markdown",
381
+ "id": "ce497a8a-00dc-4c32-b6aa-de29fe8bb404",
382
+ "metadata": {},
383
+ "source": [
384
+ "### Skipping Precomputation (Small Datasets Only)\n",
385
+ "\n",
386
+ "For small datasets, you may choose **not** to precompute embeddings at all.\n",
387
+ "Instead, the model can compute embeddings **on-the-fly** during training and evaluation.\n",
388
+ "This avoids writing anything to disk and keeps the workflow simpler — but it also makes each training epoch slower because the model recomputes embeddings every time.\n",
389
+ "\n",
390
+ "This approach is **only recommended for small tasks** or quick experimentation.\n",
391
+ "For medium or large datasets, precomputing is strongly preferred to avoid unnecessary compute overhead.\n",
392
+ "\n",
393
+ "**To skip precomputation entirely**, simply create your dataloaders from the original datasets:\n",
394
+ "\n",
395
+ "```python\n",
396
+ "# Create dataloaders without precomputing embeddings\n",
397
+ "train_loader = DataLoader(train_ds, batch_size=512, shuffle=True,\n",
398
+ " num_workers=8, collate_fn=collator)\n",
399
+ "\n",
400
+ "val_loader = DataLoader(val_ds, batch_size=512, shuffle=False,\n",
401
+ " num_workers=8, collate_fn=collator)\n",
402
+ "\n",
403
+ "test_loader = DataLoader(test_ds, batch_size=512, shuffle=False,\n",
404
+ " num_workers=8, collate_fn=collator)\n",
405
+ "```\n",
406
+ "\n",
407
+ "With this setup, embeddings are computed dynamically each time a batch is passed through the model."
408
+ ]
409
+ },
410
+ {
411
+ "cell_type": "markdown",
412
+ "id": "e48673a2-3f4d-4336-b562-9e1f433378ee",
413
+ "metadata": {
414
+ "jp-MarkdownHeadingCollapsed": true
415
+ },
416
+ "source": [
417
+ "## Instantiating the Model\n",
418
+ "\n",
419
+ "We use the `ForecastingModel` class to create a forecasting model that predicts future time steps of a time series using embeddings from CHARM. Given an input window of length `input_size`,\n",
420
+ "the model:\n",
421
+ "\n",
422
+ "1. **Computes embeddings** \n",
423
+ " Calls the embedding client to obtain embeddings (or uses pre-computed ones) of shape **[B, T_p, C, D]**, where `T_p`\n",
424
+ " is the number of temporal patches.\n",
425
+ "\n",
426
+ "2. **Pools embeddings over time** (`mode`):\n",
427
+ " - **\"last\"** – use the final temporal patch \n",
428
+ " - **\"avg\"** – average across all patches \n",
429
+ " - **\"none\"** – flatten all patches (most expressive)\n",
430
+ "\n",
431
+ "3. **Feeds pooled embeddings into forecasting heads** \n",
432
+ " Depending on `per_channel`:\n",
433
+ " - A **separate head per channel**, or \n",
434
+ " - A **shared head** across channels. \n",
435
+ " Heads can be `\"linear\"`, `\"mlp\"`, or a custom module.\n",
436
+ "\n",
437
+ "4. **Outputs forecasts** \n",
438
+ " Produces predictions of shape **[B, horizon, C]**, then rescales using the stored\n",
439
+ " input mean and std."
440
+ ]
441
+ },
442
+ {
443
+ "cell_type": "code",
444
+ "execution_count": null,
445
+ "id": "fa09e6b6-75b2-467a-9746-6d2c13df8585",
446
+ "metadata": {
447
+ "jupyter": {
448
+ "source_hidden": true
449
+ }
450
+ },
451
+ "outputs": [],
452
+ "source": [
453
+ "from charm_toolkit.models import ForecastingModel\n",
454
+ "\n",
455
+ "model = ForecastingModel(\n",
456
+ " embedding_client=client, \n",
457
+ "\n",
458
+ " head=\"linear\", \n",
459
+ " # Supported options:\n",
460
+ " # \"linear\" → simple Linear decoder\n",
461
+ " # \"mlp\" → 2-layer MLP decoder\n",
462
+ " # nn.Module instance → fully custom decoder\n",
463
+ "\n",
464
+ " input_size=96,\n",
465
+ " # Number of time steps to use as input. Should be the same one as used in \n",
466
+ " # 'create_forecasting_datasets' for train_horizon\n",
467
+ " \n",
468
+ " hidden_dim=128, \n",
469
+ " # Used only for MLP-style forecaster.\n",
470
+ " # Controls width of intermediate layers.\n",
471
+ "\n",
472
+ " dropout=0.0, \n",
473
+ " # Dropout applied\n",
474
+ "\n",
475
+ " horizon= 96,\n",
476
+ " # Horizon to forecast. Should be the same one as used in \n",
477
+ " # 'create_forecasting_datasets' for test_horizon\n",
478
+ "\n",
479
+ " mode= \"last\",\n",
480
+ " # Pooling mode for embeddings (\"last\", \"avg\", \"none\")\n",
481
+ "\n",
482
+ " per_channel = True,\n",
483
+ " # Whether to use separate forecasting heads for each channel.\n",
484
+ "\n",
485
+ " num_channels= 21,\n",
486
+ " # Number of channels in the time series (required if per_channel=True)\n",
487
+ ")"
488
+ ]
489
+ },
490
+ {
491
+ "cell_type": "markdown",
492
+ "id": "3cc41797-cca5-4b35-97c4-aacfe6de2fd9",
493
+ "metadata": {
494
+ "jp-MarkdownHeadingCollapsed": true
495
+ },
496
+ "source": [
497
+ "## Setting Up the Trainer\n",
498
+ "\n",
499
+ "Having defined the model architecture, we can now train it. The `Trainer` class provides a lightweight, flexible training loop for PyTorch models,\n",
500
+ "including support for validation, early stopping, gradient accumulation, gradient clipping,\n",
501
+ "learning-rate scheduling, callbacks, and basic visualization snapshots.\n",
502
+ "\n",
503
+ "A `Trainer` instance is created with:\n",
504
+ "- a model (`nn.Module`)\n",
505
+ "- `train_loader`, and optionally `val_loader` / `test_loader`\n",
506
+ "- training hyperparameters such as learning rate, weight decay, epochs,\n",
507
+ " gradient accumulation steps, and early-stopping parameters.\n",
508
+ "\n",
509
+ "During training (`fit()`):\n",
510
+ "1. **Each epoch streams batches** from the `DataLoader` (no full-dataset loading).\n",
511
+ "2. For each batch, it:\n",
512
+ " - runs a forward pass\n",
513
+ " - computes the loss\n",
514
+ " - performs optional gradient accumulation (`accum_steps`)\n",
515
+ " - applies gradient clipping (`max_grad_norm`)\n",
516
+ " - updates the optimizer\n",
517
+ "3. After each epoch, it:\n",
518
+ " - evaluates on the validation set (if provided)\n",
519
+ " - steps the scheduler (e.g., `ReduceLROnPlateau`)\n",
520
+ " - triggers any user-defined callbacks\n",
521
+ " - checks early-stopping conditions (`patience`, `min_delta`)\n",
522
+ "4. The best model (lowest validation loss) is stored and restored at the end.\n",
523
+ "\n",
524
+ "During validation, the trainer also stores a small number of snapshots (targets, predictions, inputs) for visualization. The `evaluate()` method runs a full validation/testing pass and returns the average loss."
525
+ ]
526
+ },
527
+ {
528
+ "cell_type": "code",
529
+ "execution_count": null,
530
+ "id": "5721f474-5b7f-49cb-9df9-c97bf75f5645",
531
+ "metadata": {},
532
+ "outputs": [],
533
+ "source": [
534
+ "from charm_toolkit.Trainer import Trainer\n",
535
+ "from charm_toolkit.Plots import PlotCallback\n",
536
+ "\n",
537
+ "plot_cb = PlotCallback(\n",
538
+ " every=3, \n",
539
+ " # Plot every N steps\n",
540
+ " #TODO\n",
541
+ " show_forecast=True, \n",
542
+ " # Plot original vs forecasted\n",
543
+ "\n",
544
+ " n_samples=2, \n",
545
+ " # number of samples to plot\n",
546
+ "\n",
547
+ " channels=[1,2,3], \n",
548
+ " # int or list of channels to visualize\n",
549
+ ")\n",
550
+ "\n",
551
+ "\n",
552
+ "trainer = Trainer(\n",
553
+ " model=model,\n",
554
+ " train_loader=train_loader,\n",
555
+ " val_loader=val_loader,\n",
556
+ " test_loader=None, \n",
557
+ " # optional — only needed if you call trainer.test()\n",
558
+ "\n",
559
+ " lr=1e-2, \n",
560
+ " # learning rate. Default:1e-3\n",
561
+ "\n",
562
+ " weight_decay=1e-4, \n",
563
+ " # L2 regularization. Default:1e-4\n",
564
+ "\n",
565
+ " epochs=1000, \n",
566
+ " # maximum training epochs. Default:1000\n",
567
+ "\n",
568
+ " accum_steps=1, \n",
569
+ " # gradient accumulation:\n",
570
+ " # if >1: effectively increases batch size without increasing memory\n",
571
+ " # Default: 1\n",
572
+ "\n",
573
+ " patience=10, \n",
574
+ " # early stopping patience. Default: 5\n",
575
+ "\n",
576
+ " min_delta=1e-4, \n",
577
+ " # minimum improvement required for early stopping. Default: 1e-4\n",
578
+ "\n",
579
+ " max_grad_norm=5.0, \n",
580
+ " # gradient clipping for stability. Default: 5\n",
581
+ "\n",
582
+ " device=None, \n",
583
+ " # default: \"cuda\" if available else \"cpu\"\n",
584
+ "\n",
585
+ " save_dir=\"./forecasting_checkpoints\",\n",
586
+ " # where best checkpoints are saved\n",
587
+ "\n",
588
+ " callbacks=[plot_cb], \n",
589
+ " # supports ANY number of callbacks:\n",
590
+ " # - plotting\n",
591
+ " # - logging (wandb, tensorboard)\n",
592
+ " # - checkpointing\n",
593
+ " # - custom user hooks\n",
594
+ "\n",
595
+ " criterion=None, \n",
596
+ " # default: MSELoss\n",
597
+ " # user may pass:\n",
598
+ " # nn.L1Loss()\n",
599
+ " # nn.HuberLoss()\n",
600
+ " # custom callable loss\n",
601
+ "\n",
602
+ " optimizer=None, \n",
603
+ " # default: AdamW\n",
604
+ " # user may pass:\n",
605
+ " # torch.optim.SGD\n",
606
+ " # torch.optim.Adam\n",
607
+ " # any custom optimizer\n",
608
+ "\n",
609
+ " scheduler=None, \n",
610
+ " # default: ReduceLROnPlateau\n",
611
+ " # user may pass:\n",
612
+ " # StepLR\n",
613
+ " # OneCycleLR\n",
614
+ " # custom scheduler\n",
615
+ ")\n"
616
+ ]
617
+ },
618
+ {
619
+ "cell_type": "code",
620
+ "execution_count": null,
621
+ "id": "c708a47e-6175-42da-a783-5dccfdfb0b37",
622
+ "metadata": {},
623
+ "outputs": [],
624
+ "source": [
625
+ "# How training is triggered!\n",
626
+ "trainer.fit()\n"
627
+ ]
628
+ },
629
+ {
630
+ "cell_type": "markdown",
631
+ "id": "7d10f91e-7bf9-4950-b64a-ad2fea60f2a9",
632
+ "metadata": {},
633
+ "source": [
634
+ "## Using our Model for Inference\n",
635
+ "By default, our models use the training statistics to standardize the data for stable training dynamics.\n",
636
+ "Each dataset contains a scaler, which we can use to transform predictions back into the original data space."
637
+ ]
638
+ },
639
+ {
640
+ "cell_type": "code",
641
+ "execution_count": null,
642
+ "id": "b17609b9-91d5-4863-a70c-f383ab355380",
643
+ "metadata": {},
644
+ "outputs": [],
645
+ "source": [
646
+ "import random\n",
647
+ "import matplotlib.pyplot as plt\n",
648
+ "\n",
649
+ "plt.style.use(\"seaborn-v0_8-darkgrid\")\n",
650
+ "\n",
651
+ "n = 3\n",
652
+ "channels_to_plot = [1, 2, 3]\n",
653
+ "\n",
654
+ "# Random indices\n",
655
+ "random.seed(42)\n",
656
+ "indices = random.sample(range(len(test_ds)), n)\n",
657
+ "\n",
658
+ "model.eval()\n",
659
+ "for idx in indices:\n",
660
+ "\n",
661
+ " # ---- load sample ----\n",
662
+ " x, y = test_ds[idx]\n",
663
+ " context = x[\"ts_array\"]\n",
664
+ "\n",
665
+ " pred_norm = model(x)\n",
666
+ "\n",
667
+ " # ---- numpy ----\n",
668
+ " y_np = y.squeeze(0).cpu().numpy()\n",
669
+ " pred_np = pred_norm.squeeze(0).cpu().detach().numpy()\n",
670
+ " ctx_np = context.squeeze(0).cpu().numpy()\n",
671
+ "\n",
672
+ " # ---- unnormalize ----\n",
673
+ " scaler = getattr(test_ds.dataset, \"scaler\", None)\n",
674
+ " if scaler is not None:\n",
675
+ " ctx_un = scaler.inverse_transform(ctx_np)\n",
676
+ " y_un = scaler.inverse_transform(y_np)\n",
677
+ " pred_un = scaler.inverse_transform(pred_np)\n",
678
+ " else:\n",
679
+ " ctx_un, y_un, pred_un = ctx_np, y_np, pred_np\n",
680
+ "\n",
681
+ " # ---- time axis ----\n",
682
+ " T_ctx = ctx_un.shape[0]\n",
683
+ " T_fut = y_un.shape[0]\n",
684
+ " t_ctx = range(-T_ctx, 0)\n",
685
+ " t_fut = range(0, T_fut)\n",
686
+ "\n",
687
+ " # ---- create subplots ----\n",
688
+ " fig, axes = plt.subplots(\n",
689
+ " len(channels_to_plot),\n",
690
+ " 1,\n",
691
+ " figsize=(11, 4 * len(channels_to_plot)),\n",
692
+ " sharex=True\n",
693
+ " )\n",
694
+ "\n",
695
+ " # If only one axis, wrap it into a list\n",
696
+ " if len(channels_to_plot) == 1:\n",
697
+ " axes = [axes]\n",
698
+ "\n",
699
+ " colors = [\"#1f77b4\", \"#d62728\", \"#2ca02c\", \"#9467bd\"]\n",
700
+ "\n",
701
+ " # ---- plot each channel separately ----\n",
702
+ " for i, (ax, ch) in enumerate(zip(axes, channels_to_plot)):\n",
703
+ "\n",
704
+ " col = colors[i % len(colors)]\n",
705
+ "\n",
706
+ " # context\n",
707
+ " ax.plot(t_ctx, ctx_un[:, ch], color=col, alpha=0.6,\n",
708
+ " label=f\"Context (ch{ch})\", linewidth=1.8)\n",
709
+ "\n",
710
+ " # target\n",
711
+ " ax.plot(t_fut, y_un[:, ch], color=col,\n",
712
+ " label=f\"Target (ch{ch})\", linewidth=2.2)\n",
713
+ "\n",
714
+ " # forecast\n",
715
+ " ax.plot(t_fut, pred_un[:, ch], linestyle=\"--\", color=col,\n",
716
+ " label=f\"Forecast (ch{ch})\", linewidth=2)\n",
717
+ "\n",
718
+ " # ---- aesthetics (preserving your original style) ----\n",
719
+ " ax.axvline(0, color=\"black\", linestyle=\":\", linewidth=1.5)\n",
720
+ "\n",
721
+ " ymax = ax.get_ylim()[1]\n",
722
+ " ax.text(-0.5, ymax * 0.95, \"Context\",\n",
723
+ " ha=\"right\", va=\"top\", fontsize=10)\n",
724
+ " ax.text(0.5, ymax * 0.95, \"Forecast\",\n",
725
+ " ha=\"left\", va=\"top\", fontsize=10)\n",
726
+ "\n",
727
+ " ax.set_ylabel(\"Value\", fontsize=12)\n",
728
+ " ax.set_title(f\"Channel {ch}\", fontsize=14)\n",
729
+ "\n",
730
+ " ax.legend(loc=\"upper left\", ncol=2, fontsize=10, frameon=True)\n",
731
+ "\n",
732
+ " # ---- global labels ----\n",
733
+ " fig.suptitle(f\"Forecasting Example {idx}\", fontsize=16, y=1.02)\n",
734
+ " fig.supxlabel(\"Time\", fontsize=12)\n",
735
+ "\n",
736
+ " plt.tight_layout()\n",
737
+ " fig.subplots_adjust(hspace=0.15)\n",
738
+ " plt.show()\n"
739
+ ]
740
+ },
741
+ {
742
+ "cell_type": "code",
743
+ "execution_count": null,
744
+ "id": "b1c24063-ab63-459a-bc34-68b0f0569f11",
745
+ "metadata": {},
746
+ "outputs": [],
747
+ "source": []
748
+ }
749
+ ],
750
+ "metadata": {
751
+ "kernelspec": {
752
+ "display_name": "charm",
753
+ "language": "python",
754
+ "name": "ftse"
755
+ },
756
+ "language_info": {
757
+ "codemirror_mode": {
758
+ "name": "ipython",
759
+ "version": 3
760
+ },
761
+ "file_extension": ".py",
762
+ "mimetype": "text/x-python",
763
+ "name": "python",
764
+ "nbconvert_exporter": "python",
765
+ "pygments_lexer": "ipython3",
766
+ "version": "3.11.4"
767
+ }
768
+ },
769
+ "nbformat": 4,
770
+ "nbformat_minor": 5
771
+ }
notebooks/demo_reconstruction.ipynb ADDED
@@ -0,0 +1,710 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "id": "62b712f6-5a23-4de9-af1d-9cb94191f111",
6
+ "metadata": {},
7
+ "source": [
8
+ "# Time Series Reconstruction with Charm Embeddings\n",
9
+ "\n",
10
+ "This notebook walks through a full example of **time-series reconstruction** using:\n",
11
+ "\n",
12
+ "- A synthetic multi-channel sinewave dataset \n",
13
+ "- Your **CharmClient** for embeddings \n",
14
+ "- Your custom **ReconstructionModel** \n",
15
+ "- A clean dataset pipeline with sliding windows \n",
16
+ "- The training loop powered by your custom **Trainer** \n",
17
+ "- A visualization callback that plots reconstructions during training \n",
18
+ "\n",
19
+ "The goal is pedagogical: understand *every single component* of the library.\n"
20
+ ]
21
+ },
22
+ {
23
+ "cell_type": "markdown",
24
+ "id": "97f74300-819e-4a66-8fe7-048c4fe8976a",
25
+ "metadata": {
26
+ "jp-MarkdownHeadingCollapsed": true
27
+ },
28
+ "source": [
29
+ "## Setup: Imports & Charm Client"
30
+ ]
31
+ },
32
+ {
33
+ "cell_type": "code",
34
+ "execution_count": null,
35
+ "id": "6c96e1cb-2d56-487c-af33-9fb7eef2d7cf",
36
+ "metadata": {},
37
+ "outputs": [],
38
+ "source": [
39
+ "from charm import CharmClient\n",
40
+ "from dotenv import load_dotenv\n",
41
+ "import os\n",
42
+ "import numpy as np\n",
43
+ "import torch\n",
44
+ "from torch.utils.data import DataLoader\n",
45
+ "import matplotlib.pyplot as plt\n",
46
+ "import torch.nn as nn\n",
47
+ "\n",
48
+ "# Create a client\n",
49
+ "# Initialize the CHARM client using environment variables\n",
50
+ "\n",
51
+ "\n",
52
+ "# # Load environment variables from .env file\n",
53
+ "# load_dotenv()\n",
54
+ "\n",
55
+ "# # Get API key and base URL from environment variables\n",
56
+ "# api_key = os.getenv(\"CHARM_API_KEY\", \"sk-charm-default-key\")\n",
57
+ "# base_url = os.getenv(\"CHARM_BASE_URL\", \"EMBEDDING_MODEL_ENDPOINT\")\n",
58
+ "\n",
59
+ "api_key = \"sk-charm-default-key\"\n",
60
+ "base_url = \"EMBEDDING_MODEL_ENDPOINT\"\n",
61
+ "\n",
62
+ "client = CharmClient(\n",
63
+ " base_url=base_url,\n",
64
+ " api_key=api_key,\n",
65
+ " timeout=180,\n",
66
+ " max_retries=3\n",
67
+ ")\n"
68
+ ]
69
+ },
70
+ {
71
+ "cell_type": "markdown",
72
+ "id": "92b27303-c19d-4d9a-9cbb-180832c97171",
73
+ "metadata": {
74
+ "jp-MarkdownHeadingCollapsed": true
75
+ },
76
+ "source": [
77
+ "## Dataset: Weather\n",
78
+ "\n",
79
+ "Here we rely on the `Weather` dataset. This dataset has been seeded in the `data` folder. Let's start by visualizing this data."
80
+ ]
81
+ },
82
+ {
83
+ "cell_type": "code",
84
+ "execution_count": null,
85
+ "id": "da2ef8b4-d107-4875-970a-51525b9713ea",
86
+ "metadata": {},
87
+ "outputs": [],
88
+ "source": [
89
+ "import pandas as pd\n",
90
+ "import torch\n",
91
+ "import numpy as np\n",
92
+ "import matplotlib.pyplot as plt\n",
93
+ "from itertools import islice\n",
94
+ "\n",
95
+ "# Load the dataset\n",
96
+ "path = \"data/weather.csv\"\n",
97
+ "df = pd.read_csv(path)\n",
98
+ "\n",
99
+ "# Extract data values (exclude time/index column)\n",
100
+ "data_np = df.iloc[:, 1:].values\n",
101
+ "\n",
102
+ "# Convert to PyTorch t\n",
103
+ "tdata = torch.tensor(data_np)\n",
104
+ "\n",
105
+ "# Get channel names\n",
106
+ "channel_names = list(df.columns[1:])\n",
107
+ "\n",
108
+ "print(\"Channel names:\", channel_names)\n",
109
+ "print(\"Tensor shape:\", tdata.shape) \n",
110
+ "\n",
111
+ "# Plotting: separate y-axis for each channel\n",
112
+ "plt.style.use(\"default\")\n",
113
+ "num_channels = 5 # Number of channels to plot\n",
114
+ "colors = plt.cm.tab10(np.linspace(0, 1, num_channels))\n",
115
+ "\n",
116
+ "fig, axes = plt.subplots(num_channels, 1, figsize=(15, 3*num_channels), sharex=True)\n",
117
+ "\n",
118
+ "for i, (channel, color) in enumerate(islice(zip(channel_names, colors), num_channels)):\n",
119
+ " ax = axes[i]\n",
120
+ " ax.plot(data_np[:, i], label=channel, color=color, linewidth=1.4, alpha=0.9)\n",
121
+ " ax.set_ylabel(channel, fontsize=12)\n",
122
+ " ax.grid(True, linestyle=\"--\", alpha=0.3)\n",
123
+ " ax.legend(loc=\"upper right\", fontsize=10)\n",
124
+ "\n",
125
+ "axes[-1].set_xlabel(\"Time Index\", fontsize=12)\n",
126
+ "fig.suptitle(\"Weather Data (First 5 Channels)\", fontsize=16)\n",
127
+ "plt.tight_layout(rect=[0, 0, 1, 0.96])\n",
128
+ "plt.show()\n"
129
+ ]
130
+ },
131
+ {
132
+ "cell_type": "markdown",
133
+ "id": "c4b88dd3-6281-4988-a657-ef0509640027",
134
+ "metadata": {
135
+ "jp-MarkdownHeadingCollapsed": true
136
+ },
137
+ "source": [
138
+ "## Sliding-Window Reconstruction Dataset\n",
139
+ "\n",
140
+ "To prepare our dataset for training, validation and testing, we use the function`create_reconstruction_datasets`. This function prepares train/validation/test splits specifically for\n",
141
+ "time-series reconstruction tasks. The function\n",
142
+ "\n",
143
+ "1. **Wraps the raw time series** \n",
144
+ " Creates an `UnwindowedDataset` that stores the full sequence and its channel\n",
145
+ " descriptions.\n",
146
+ "\n",
147
+ "2. **Splits the dataset** \n",
148
+ " Divides the data into train/val/test sets using sequential splitting\n",
149
+ " (`train_ratio`, `val_ratio`).\n",
150
+ "\n",
151
+ "3. **Applies normalization** \n",
152
+ " Fits a scaler on the training set only and applies the same scaling to validation and\n",
153
+ " test sets (avoids data leakage).\n",
154
+ "\n",
155
+ "4. **Creates windowed reconstruction datasets** \n",
156
+ " Converts each split into a `ReconstructionDataset`, where each sample is a window of\n",
157
+ " length `window_size` extracted with a given `stride`.\n",
158
+ "\n",
159
+ "5. **Returns three dataset splits** \n",
160
+ " Ready for training a `ReconstructionModel`."
161
+ ]
162
+ },
163
+ {
164
+ "cell_type": "code",
165
+ "execution_count": null,
166
+ "id": "cbc47cb0-7e23-4789-ab41-6fccc50e1e05",
167
+ "metadata": {},
168
+ "outputs": [],
169
+ "source": [
170
+ "from charm_toolkit.Datasets import create_reconstruction_datasets, collator\n",
171
+ "\n",
172
+ "train_ds, val_ds, test_ds = create_reconstruction_datasets(\n",
173
+ " raw_data = tdata, # Tensor [T, C], e.g. [4096, 3]\n",
174
+ " descriptions = channel_names, # List of channel names, e.g. [\"ch0\", \"ch1\"]\n",
175
+ "\n",
176
+ " window_size = 256, # Size of each input window\n",
177
+ " stride = 1, # Step between windows (overlap control)\n",
178
+ "\n",
179
+ " train_ratio = 0.70, # 70% for training\n",
180
+ " val_ratio = 0.15, # 15% for validation\n",
181
+ "\n",
182
+ " sequential = True, # True = sequential splits; False = shuffled\n",
183
+ "\n",
184
+ " scale = True # Scale using training statistics\n",
185
+ " # Access via: train_ds.dataset.scaler\n",
186
+ ")"
187
+ ]
188
+ },
189
+ {
190
+ "cell_type": "markdown",
191
+ "id": "e89a2475-0157-479e-be25-5c0e6df21aa7",
192
+ "metadata": {
193
+ "jp-MarkdownHeadingCollapsed": true
194
+ },
195
+ "source": [
196
+ "## Zero-shot reconstruction\n",
197
+ "\n",
198
+ "Zero-shot reconstruction evaluates the model’s ability to **reconstruct a time series without any additional training or fine-tuning** on the target data. The model is given an input sequence together with the natural-language descriptions of each channel, and it must reproduce the signal using the representations it has already learned.\n",
199
+ "\n",
200
+ "In this setting, we select a single example from the test set and provide:\n",
201
+ "\n",
202
+ "* the **input time series** (`ts_array`)\n",
203
+ "* the **channel descriptions**, used by Charm to generate semantic embeddings\n",
204
+ "\n",
205
+ "The reconstruction is produced by calling the Charm prediction API. The model returns a **probabilistic reconstruction** in the form of multiple quantiles for each channel and time step. These predictions are automatically **denormalized**, allowing direct comparison with the original signal.\n"
206
+ ]
207
+ },
208
+ {
209
+ "cell_type": "code",
210
+ "execution_count": null,
211
+ "id": "38e3d04d-08d8-4b6e-94eb-27a78b2cc4ca",
212
+ "metadata": {},
213
+ "outputs": [],
214
+ "source": [
215
+ "idx = 10\n",
216
+ "recon_len = -256 # negative => reconstruction instead of forecasting\n",
217
+ "\n",
218
+ "ts_array = test_ds[idx][0][\"ts_array\"].tolist()\n",
219
+ "descriptions = test_ds[idx][0][\"descriptions\"]\n",
220
+ "\n",
221
+ "response = client.prediction.create(\n",
222
+ " ts_array=ts_array,\n",
223
+ " descriptions=descriptions,\n",
224
+ " target_len=recon_len,\n",
225
+ " return_tensors=\"np\"\n",
226
+ ")\n",
227
+ "\n",
228
+ "# Model output: (T_rec, C, Q)\n",
229
+ "preds = response.denormalized_predictions.squeeze(0)\n",
230
+ "\n",
231
+ "# Full input context\n",
232
+ "context = test_ds[idx][0][\"ts_array\"].squeeze(0)\n",
233
+ "\n",
234
+ "# Ground truth = reconstructed portion of the input\n",
235
+ "true = context"
236
+ ]
237
+ },
238
+ {
239
+ "cell_type": "code",
240
+ "execution_count": null,
241
+ "id": "4b2e884b-71d3-4c05-8870-b8072726013b",
242
+ "metadata": {},
243
+ "outputs": [],
244
+ "source": [
245
+ "# Configuration\n",
246
+ "n_channels = 3\n",
247
+ "T_rec, C, Q = preds.shape\n",
248
+ "\n",
249
+ "# Time axis (shared)\n",
250
+ "t = np.arange(T_rec)\n",
251
+ "\n",
252
+ "# Quantile indices\n",
253
+ "q_low, q_high = 0, 20 # 1st, 99th quantiles\n",
254
+ "q_med = Q // 2\n",
255
+ "\n",
256
+ "# Plot\n",
257
+ "fig, axes = plt.subplots(n_channels, 1, figsize=(14, 3 * n_channels), sharex=True)\n",
258
+ "if n_channels == 1:\n",
259
+ " axes = [axes]\n",
260
+ "\n",
261
+ "for ch in range(n_channels):\n",
262
+ " ax = axes[ch]\n",
263
+ "\n",
264
+ " # Ground truth signal\n",
265
+ " ax.plot(t, true[:, ch], color=\"black\", lw=2, label=\"True Signal\")\n",
266
+ "\n",
267
+ " # Quantile uncertainty fan\n",
268
+ " for i in range(q_low, q_med):\n",
269
+ " ax.fill_between(\n",
270
+ " t,\n",
271
+ " preds[:, ch, i],\n",
272
+ " preds[:, ch, Q - 1 - i],\n",
273
+ " color=\"red\",\n",
274
+ " alpha=0.1,\n",
275
+ " lw=0\n",
276
+ " )\n",
277
+ "\n",
278
+ " # Median reconstruction\n",
279
+ " ax.plot(\n",
280
+ " t,\n",
281
+ " preds[:, ch, q_med],\n",
282
+ " color=\"red\",\n",
283
+ " ls=\"--\",\n",
284
+ " lw=2,\n",
285
+ " label=\"Median Reconstruction\"\n",
286
+ " )\n",
287
+ "\n",
288
+ " ax.set_title(f\"Channel {ch}\")\n",
289
+ " if ch == 0:\n",
290
+ " ax.legend(loc=\"upper left\")\n",
291
+ "\n",
292
+ "axes[-1].set_xlabel(\"Time\")\n",
293
+ "plt.tight_layout()\n",
294
+ "plt.show()\n"
295
+ ]
296
+ },
297
+ {
298
+ "cell_type": "markdown",
299
+ "id": "b2de66bb-2abd-475f-a0b4-5066fd40cbb3",
300
+ "metadata": {},
301
+ "source": [
302
+ "## Moving on...\n",
303
+ "While zero-shot forecasting highlights the model’s ability to generalize without adaptation, it is only one possible usage mode. In many practical settings, better performance can be achieved by linear probing, where the Charm embeddings are kept frozen and a lightweight forecasting head is trained on task-specific data. This allows the model to retain its general semantic understanding while efficiently adapting to the statistical structure of a particular dataset."
304
+ ]
305
+ },
306
+ {
307
+ "cell_type": "markdown",
308
+ "id": "31a98fe0-a323-415f-9a53-419d844ee6de",
309
+ "metadata": {
310
+ "jp-MarkdownHeadingCollapsed": true
311
+ },
312
+ "source": [
313
+ "## Precomputing Embeddings\n",
314
+ "\n",
315
+ "It is possible to precompute the embeddings and save them into disk. This will make training faster and allow us to train different models or heads and experiment **without** having to compute the embeddings again! To this end we use the function `precompute_dataset_embeddings` that computes embeddings for an entire dataset **once** and stores them efficiently in a memory-mapped file on disk. The function works in three steps\n",
316
+ "\n",
317
+ "1. **Infer embedding shape** \n",
318
+ " It embeds a small initial batch to determine the full output shape and initializes a\n",
319
+ " `memmap` file on disk to store all embeddings.\n",
320
+ "\n",
321
+ "2. **Process dataset in batches to avoid memory issues** \n",
322
+ " It iterates through the dataset in chunks (`memory_batch_size`), computes embeddings\n",
323
+ " asynchronously using the model’s embedding client, and writes each batch directly into\n",
324
+ " the correct slice of the memmap file.\n",
325
+ "\n",
326
+ "3. **Flush and return shape** \n",
327
+ " Each batch is saved immediately, and the function returns the full embedding shape for\n",
328
+ " later use."
329
+ ]
330
+ },
331
+ {
332
+ "cell_type": "code",
333
+ "execution_count": null,
334
+ "id": "32f05b73-b663-4c37-9e9f-aa0fd3b496c0",
335
+ "metadata": {},
336
+ "outputs": [],
337
+ "source": [
338
+ "from charm_toolkit.EmbeddingUtils import precompute_dataset_embeddings, PrecomputedEmbeddingsDataset\n",
339
+ "\n",
340
+ "# Precompute embeddings\n",
341
+ "emb_shape_train = precompute_dataset_embeddings(\n",
342
+ " client=client,\n",
343
+ " dataset=train_ds,\n",
344
+ " output_path=\"./outputs/train_embeddings.pt\",\n",
345
+ " memory_batch_size=8192\n",
346
+ ")\n",
347
+ "emb_shape_val = precompute_dataset_embeddings(\n",
348
+ " client=client,\n",
349
+ " dataset=val_ds,\n",
350
+ " output_path=\"./outputs/val_embeddings.pt\",\n",
351
+ " memory_batch_size=8192\n",
352
+ ")\n",
353
+ "emb_shape_test = precompute_dataset_embeddings(\n",
354
+ " client=client,\n",
355
+ " dataset=test_ds,\n",
356
+ " output_path=\"./outputs/test_embeddings.pt\",\n",
357
+ " memory_batch_size=8192\n",
358
+ ")\n",
359
+ "\n",
360
+ "\n",
361
+ "# # # Create datasets with precomputed embeddings\n",
362
+ "train_dataset_with_embeddings = PrecomputedEmbeddingsDataset(\n",
363
+ " train_ds,\n",
364
+ " \"./outputs/train_embeddings.pt\",\n",
365
+ " emb_shape_train,\n",
366
+ ")\n",
367
+ "\n",
368
+ "val_dataset_with_embeddings = PrecomputedEmbeddingsDataset(\n",
369
+ " val_ds,\n",
370
+ " \"./outputs/val_embeddings.pt\",\n",
371
+ " emb_shape_val,\n",
372
+ ")\n",
373
+ "test_dataset_with_embeddings = PrecomputedEmbeddingsDataset(\n",
374
+ " test_ds,\n",
375
+ " \"./outputs/test_embeddings.pt\",\n",
376
+ " emb_shape_test,\n",
377
+ ")\n",
378
+ "\n",
379
+ "# Create dataloaders\n",
380
+ "train_loader = DataLoader(train_dataset_with_embeddings, batch_size=512, shuffle=True, num_workers=1, collate_fn=collator)\n",
381
+ "val_loader = DataLoader(val_dataset_with_embeddings, batch_size=512, shuffle=False, num_workers=1, collate_fn=collator)\n",
382
+ "test_loader = DataLoader(test_dataset_with_embeddings, batch_size=512, shuffle=False, num_workers=1, collate_fn=collator)"
383
+ ]
384
+ },
385
+ {
386
+ "cell_type": "markdown",
387
+ "id": "94195f07-aafb-4b07-9dcf-d5eaae6b1044",
388
+ "metadata": {},
389
+ "source": [
390
+ "### Skipping Precomputation (Small Datasets Only)\n",
391
+ "\n",
392
+ "For small datasets, you may choose **not** to precompute embeddings at all.\n",
393
+ "Instead, the model can compute embeddings **on-the-fly** during training and evaluation.\n",
394
+ "This avoids writing anything to disk and keeps the workflow simpler — but it also makes each training epoch slower because the model recomputes embeddings every time.\n",
395
+ "\n",
396
+ "This approach is **only recommended for small tasks** or quick experimentation.\n",
397
+ "For medium or large datasets, precomputing is strongly preferred to avoid unnecessary compute overhead.\n",
398
+ "\n",
399
+ "**To skip precomputation entirely**, simply create your dataloaders from the original datasets:\n",
400
+ "\n",
401
+ "```python\n",
402
+ "# Create dataloaders without precomputing embeddings\n",
403
+ "train_loader = DataLoader(train_ds, batch_size=512, shuffle=True,\n",
404
+ " num_workers=1, collate_fn=collator)\n",
405
+ "\n",
406
+ "val_loader = DataLoader(val_ds, batch_size=512, shuffle=False,\n",
407
+ " num_workers=1, collate_fn=collator)\n",
408
+ "\n",
409
+ "test_loader = DataLoader(test_ds, batch_size=512, shuffle=False,\n",
410
+ " num_workers=1, collate_fn=collator)\n",
411
+ "```\n",
412
+ "\n",
413
+ "With this setup, embeddings are computed dynamically each time a batch is passed through the model."
414
+ ]
415
+ },
416
+ {
417
+ "cell_type": "markdown",
418
+ "id": "e48673a2-3f4d-4336-b562-9e1f433378ee",
419
+ "metadata": {
420
+ "jp-MarkdownHeadingCollapsed": true
421
+ },
422
+ "source": [
423
+ "## Instantiating the Model\n",
424
+ "\n",
425
+ "We use the `ReconstructionModel` class to define a reconstruction model that reconstructs the original time series from its embeddings, using an\n",
426
+ "external embedding client (e.g., CHARM) as the encoder and a small neural network\n",
427
+ "(reconstructor) as the decoder.\n",
428
+ "\n",
429
+ "The model:\n",
430
+ "\n",
431
+ "1. **Computes embeddings** \n",
432
+ " Converts the input time series into patch embeddings of shape **[B, T_p, C, D]**.\n",
433
+ "\n",
434
+ "2. **Decodes each embedding** \n",
435
+ " Applies a reconstruction head (either `\"linear\"`, `\"mlp\"`, or a custom module) that\n",
436
+ " maps each embedding vector of size **D** back to a patch of length `patch_size`.\n",
437
+ "\n",
438
+ "3. **Reassembles patches** \n",
439
+ " Rearranges and reshapes the decoded patches back into a full sequence of shape \n",
440
+ " **[B, T, C]**.\n",
441
+ "\n",
442
+ "4. **Denormalizes the output** \n",
443
+ " Uses the input mean and standard deviation from the forward pass to return the\n",
444
+ " reconstructed time series in the original scale."
445
+ ]
446
+ },
447
+ {
448
+ "cell_type": "code",
449
+ "execution_count": null,
450
+ "id": "fa09e6b6-75b2-467a-9746-6d2c13df8585",
451
+ "metadata": {},
452
+ "outputs": [],
453
+ "source": [
454
+ "from charm_toolkit.models import ReconstructionModel\n",
455
+ "\n",
456
+ "model = ReconstructionModel(\n",
457
+ " embedding_client=client, \n",
458
+ "\n",
459
+ " reconstructor=\"linear\", \n",
460
+ " # Supported options:\n",
461
+ " # \"linear\" → simple Linear decoder\n",
462
+ " # \"mlp\" → 2-layer MLP decoder\n",
463
+ " # nn.Module instance → fully custom decoder\n",
464
+ " \n",
465
+ " hidden_dim=128, \n",
466
+ " # Used only for MLP-style reconstructors.\n",
467
+ " # Controls width of intermediate layers.\n",
468
+ "\n",
469
+ " dropout=0.1, \n",
470
+ " # Dropout applied\n",
471
+ ")\n"
472
+ ]
473
+ },
474
+ {
475
+ "cell_type": "markdown",
476
+ "id": "3cc41797-cca5-4b35-97c4-aacfe6de2fd9",
477
+ "metadata": {
478
+ "jp-MarkdownHeadingCollapsed": true
479
+ },
480
+ "source": [
481
+ "## Setting Up the Trainer\n",
482
+ "\n",
483
+ "Having defined the model architecture, we can now train it. The `Trainer` class provides a lightweight, flexible training loop for PyTorch models,\n",
484
+ "including support for validation, early stopping, gradient accumulation, gradient clipping,\n",
485
+ "learning-rate scheduling, callbacks, and basic visualization snapshots.\n",
486
+ "\n",
487
+ "A `Trainer` instance is created with:\n",
488
+ "- a model (`nn.Module`)\n",
489
+ "- `train_loader`, and optionally `val_loader` / `test_loader`\n",
490
+ "- training hyperparameters such as learning rate, weight decay, epochs,\n",
491
+ " gradient accumulation steps, and early-stopping parameters.\n",
492
+ "\n",
493
+ "During training (`fit()`):\n",
494
+ "1. **Each epoch streams batches** from the `DataLoader` (no full-dataset loading).\n",
495
+ "2. For each batch, it:\n",
496
+ " - runs a forward pass\n",
497
+ " - computes the loss\n",
498
+ " - performs optional gradient accumulation (`accum_steps`)\n",
499
+ " - applies gradient clipping (`max_grad_norm`)\n",
500
+ " - updates the optimizer\n",
501
+ "3. After each epoch, it:\n",
502
+ " - evaluates on the validation set (if provided)\n",
503
+ " - steps the scheduler (e.g., `ReduceLROnPlateau`)\n",
504
+ " - triggers any user-defined callbacks\n",
505
+ " - checks early-stopping conditions (`patience`, `min_delta`)\n",
506
+ "4. The best model (lowest validation loss) is stored and restored at the end.\n",
507
+ "\n",
508
+ "During validation, the trainer also stores a small number of snapshots (targets, predictions, inputs) for visualization. The `evaluate()` method runs a full validation/testing pass and returns the average loss."
509
+ ]
510
+ },
511
+ {
512
+ "cell_type": "code",
513
+ "execution_count": null,
514
+ "id": "5721f474-5b7f-49cb-9df9-c97bf75f5645",
515
+ "metadata": {},
516
+ "outputs": [],
517
+ "source": [
518
+ "from charm_toolkit.Trainer import Trainer\n",
519
+ "from charm_toolkit.Plots import PlotCallback\n",
520
+ "\n",
521
+ "plot_cb = PlotCallback(\n",
522
+ " every=3, \n",
523
+ " # Plot every N steps\n",
524
+ "\n",
525
+ " show_recon=True, \n",
526
+ " # Plot original vs reconstructed\n",
527
+ "\n",
528
+ " n_samples=3, \n",
529
+ " # number of samples to plot\n",
530
+ "\n",
531
+ " channels=[0,1], \n",
532
+ " # int or list of channels to visualize\n",
533
+ ")\n",
534
+ "\n",
535
+ "\n",
536
+ "trainer = Trainer(\n",
537
+ " model=model,\n",
538
+ " train_loader=train_loader,\n",
539
+ " val_loader=val_loader,\n",
540
+ " test_loader=None, \n",
541
+ " # optional — only needed if you call trainer.test()\n",
542
+ "\n",
543
+ " lr=1e-3, \n",
544
+ " # learning rate. Default:1e-3\n",
545
+ "\n",
546
+ " weight_decay=1e-3, \n",
547
+ " # L2 regularization. Default:1e-4\n",
548
+ "\n",
549
+ " epochs=1000, \n",
550
+ " # maximum training epochs. Default:1000\n",
551
+ "\n",
552
+ " accum_steps=1, \n",
553
+ " # gradient accumulation:\n",
554
+ " # if >1: effectively increases batch size without increasing memory\n",
555
+ " # Default: 1\n",
556
+ "\n",
557
+ " patience=5, \n",
558
+ " # early stopping patience. Default: 5\n",
559
+ "\n",
560
+ " min_delta=1e-4, \n",
561
+ " # minimum improvement required for early stopping. Default: 1e-4\n",
562
+ "\n",
563
+ " max_grad_norm=5.0, \n",
564
+ " # gradient clipping for stability. Default: 5\n",
565
+ "\n",
566
+ " device=None, \n",
567
+ " # default: \"cuda\" if available else \"cpu\"\n",
568
+ "\n",
569
+ " save_dir=\"./reconstruction_checkpoints\",\n",
570
+ " # where best checkpoints are saved\n",
571
+ "\n",
572
+ " callbacks=[plot_cb], \n",
573
+ " # supports ANY number of callbacks:\n",
574
+ " # - plotting\n",
575
+ " # - logging (wandb, tensorboard)\n",
576
+ " # - checkpointing\n",
577
+ " # - custom user hooks\n",
578
+ "\n",
579
+ " criterion=nn.HuberLoss(), \n",
580
+ " # default: MSELoss\n",
581
+ " # user may pass:\n",
582
+ " # nn.L1Loss()\n",
583
+ " # nn.HuberLoss()\n",
584
+ " # custom callable loss\n",
585
+ "\n",
586
+ " optimizer=None, \n",
587
+ " # default: AdamW\n",
588
+ " # user may pass:\n",
589
+ " # torch.optim.SGD\n",
590
+ " # torch.optim.Adam\n",
591
+ " # any custom optimizer\n",
592
+ "\n",
593
+ " scheduler=None, \n",
594
+ " # default: ReduceLROnPlateau\n",
595
+ " # user may pass:\n",
596
+ " # StepLR\n",
597
+ " # OneCycleLR\n",
598
+ " # custom scheduler\n",
599
+ ")"
600
+ ]
601
+ },
602
+ {
603
+ "cell_type": "code",
604
+ "execution_count": null,
605
+ "id": "c708a47e-6175-42da-a783-5dccfdfb0b37",
606
+ "metadata": {},
607
+ "outputs": [],
608
+ "source": [
609
+ "# How training is triggered!\n",
610
+ "trainer.fit()\n"
611
+ ]
612
+ },
613
+ {
614
+ "cell_type": "markdown",
615
+ "id": "62d7e5b0-b909-4397-bc72-07f46148e1d4",
616
+ "metadata": {
617
+ "jp-MarkdownHeadingCollapsed": true
618
+ },
619
+ "source": [
620
+ "## Using the Trained Model for Inference\n",
621
+ "By default, our models use the training statistics to standardize the data for stable training dynamics.\n",
622
+ "Each dataset contains a scaler, which we can use to transform predictions back into the original data space."
623
+ ]
624
+ },
625
+ {
626
+ "cell_type": "code",
627
+ "execution_count": null,
628
+ "id": "28c9d4fb-ca7a-47f8-98ff-3d65af4914ec",
629
+ "metadata": {},
630
+ "outputs": [],
631
+ "source": [
632
+ "import random\n",
633
+ "import matplotlib.pyplot as plt\n",
634
+ "\n",
635
+ "n = 3\n",
636
+ "channels_to_plot = [0, 1, 2, 3, 4, 5]\n",
637
+ "\n",
638
+ "model.eval()\n",
639
+ "random.seed(42)\n",
640
+ "indices = random.sample(range(len(test_ds)), n)\n",
641
+ "\n",
642
+ "for idx in indices:\n",
643
+ " x, y = test_ds[idx]\n",
644
+ " inputs = x[\"ts_array\"]\n",
645
+ "\n",
646
+ " with torch.no_grad():\n",
647
+ " pred_norm = model(x)\n",
648
+ "\n",
649
+ " y_np = y.squeeze(0).cpu().numpy()\n",
650
+ " pred_np = pred_norm.squeeze(0).cpu().numpy()\n",
651
+ "\n",
652
+ " scaler = getattr(test_ds.dataset, 'scaler', None)\n",
653
+ " if scaler is not None:\n",
654
+ " y_un = scaler.inverse_transform(y_np)\n",
655
+ " pred_un = scaler.inverse_transform(pred_np)\n",
656
+ " else:\n",
657
+ " y_un, pred_un = y_np, pred_np\n",
658
+ "\n",
659
+ " # ---- plotting ----\n",
660
+ " C = len(channels_to_plot)\n",
661
+ " fig, axes = plt.subplots(C, 1, figsize=(10, 2*C), sharex=True)\n",
662
+ "\n",
663
+ " for ax, ch in zip(axes, channels_to_plot):\n",
664
+ " ax.plot(y_un[:, ch], label=\"target\")\n",
665
+ " ax.plot(pred_un[:, ch], '--', label=\"pred\")\n",
666
+ " ax.set_title(f\"Channel {ch}\")\n",
667
+ " ax.grid(True)\n",
668
+ " ax.legend(loc=\"upper right\")\n",
669
+ "\n",
670
+ " fig.suptitle(f\"Example {idx}\", fontsize=14)\n",
671
+ " plt.tight_layout()\n",
672
+ " plt.show()\n",
673
+ "\n"
674
+ ]
675
+ },
676
+ {
677
+ "cell_type": "code",
678
+ "execution_count": null,
679
+ "id": "5c8e135e-a165-4785-ab1c-148726b08b50",
680
+ "metadata": {
681
+ "jupyter": {
682
+ "source_hidden": true
683
+ }
684
+ },
685
+ "outputs": [],
686
+ "source": []
687
+ }
688
+ ],
689
+ "metadata": {
690
+ "kernelspec": {
691
+ "display_name": "charm",
692
+ "language": "python",
693
+ "name": "ftse"
694
+ },
695
+ "language_info": {
696
+ "codemirror_mode": {
697
+ "name": "ipython",
698
+ "version": 3
699
+ },
700
+ "file_extension": ".py",
701
+ "mimetype": "text/x-python",
702
+ "name": "python",
703
+ "nbconvert_exporter": "python",
704
+ "pygments_lexer": "ipython3",
705
+ "version": "3.11.4"
706
+ }
707
+ },
708
+ "nbformat": 4,
709
+ "nbformat_minor": 5
710
+ }
notebooks/demo_retrieval_anomaly_detection.ipynb ADDED
@@ -0,0 +1,620 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "# CHARM — Retrieval & Anomaly Detection"
8
+ ]
9
+ },
10
+ {
11
+ "cell_type": "code",
12
+ "execution_count": null,
13
+ "metadata": {
14
+ "jupyter": {
15
+ "source_hidden": true
16
+ },
17
+ "tags": [
18
+ "hide-input"
19
+ ]
20
+ },
21
+ "outputs": [],
22
+ "source": [
23
+ "import os\n",
24
+ "import numpy as np\n",
25
+ "import matplotlib.pyplot as plt\n",
26
+ "from matplotlib.patches import Patch\n",
27
+ "from sklearn.manifold import TSNE\n",
28
+ "\n",
29
+ "from charm import CharmClient\n",
30
+ "from charm_toolkit.retrieval import cosine_similarity_matrix, retrieval_metrics\n",
31
+ "from charm_toolkit.anomaly_detection import (\n",
32
+ " sliding_window_embeddings,\n",
33
+ " knn_anomaly_scores,\n",
34
+ " window_scores_to_pointwise,\n",
35
+ ")\n",
36
+ "# Zero-shot recipes use sklearn / pyod directly:\n",
37
+ "from sklearn.ensemble import IsolationForest\n",
38
+ "\n",
39
+ "plt.rcParams.update({\n",
40
+ " \"figure.facecolor\": \"white\",\n",
41
+ " \"axes.facecolor\": \"#FAFAFA\",\n",
42
+ " \"axes.grid\": False,\n",
43
+ " \"axes.spines.top\": False,\n",
44
+ " \"axes.spines.right\": False,\n",
45
+ " \"font.size\": 10,\n",
46
+ " \"axes.titlesize\": 11,\n",
47
+ " \"axes.titleweight\": \"bold\",\n",
48
+ "})\n",
49
+ "\n",
50
+ "client = CharmClient(\n",
51
+ " base_url=os.environ[\"CHARM_BASE_URL\"],\n",
52
+ " api_key=os.environ.get(\"CHARM_API_KEY\", \"\"),\n",
53
+ " timeout=300,\n",
54
+ ")"
55
+ ]
56
+ },
57
+ {
58
+ "cell_type": "markdown",
59
+ "metadata": {},
60
+ "source": [
61
+ "---\n",
62
+ "## 1 — Retrieval\n",
63
+ "\n",
64
+ "Embed three classes of synthetic time series, then retrieve the most similar ones. \n",
65
+ "Classes: **sine** (oscillation), **spike** (sharp transients), **trend** (ramp)."
66
+ ]
67
+ },
68
+ {
69
+ "cell_type": "code",
70
+ "execution_count": null,
71
+ "metadata": {
72
+ "jupyter": {
73
+ "source_hidden": true
74
+ },
75
+ "tags": [
76
+ "hide-input"
77
+ ]
78
+ },
79
+ "outputs": [],
80
+ "source": [
81
+ "np.random.seed(42)\n",
82
+ "\n",
83
+ "T, C, N = 128, 2, 6\n",
84
+ "PALETTE = {\"sine\": \"#1976D2\", \"spike\": \"#E53935\", \"trend\": \"#43A047\"}\n",
85
+ "\n",
86
+ "\n",
87
+ "def generate_sine(n):\n",
88
+ " out = []\n",
89
+ " for _ in range(n):\n",
90
+ " freq = np.random.uniform(0.5, 2.0)\n",
91
+ " phase = np.random.uniform(0, 2 * np.pi)\n",
92
+ " t = np.linspace(0, 4 * np.pi, T)\n",
93
+ " s = np.stack([np.sin(freq * t + phase + c * 0.5) for c in range(C)], axis=-1)\n",
94
+ " out.append(s + np.random.randn(T, C) * 0.05)\n",
95
+ " return out\n",
96
+ "\n",
97
+ "\n",
98
+ "def generate_spike(n):\n",
99
+ " out = []\n",
100
+ " for _ in range(n):\n",
101
+ " s = np.random.randn(T, C) * 0.1\n",
102
+ " for _ in range(np.random.randint(3, 8)):\n",
103
+ " s[np.random.randint(0, T)] += np.random.randn(C) * 5.0\n",
104
+ " out.append(s)\n",
105
+ " return out\n",
106
+ "\n",
107
+ "\n",
108
+ "def generate_trend(n):\n",
109
+ " out = []\n",
110
+ " for _ in range(n):\n",
111
+ " slope = np.random.uniform(-2, 2, size=(1, C))\n",
112
+ " t = np.linspace(0, 1, T).reshape(-1, 1)\n",
113
+ " out.append(t * slope + np.random.randn(T, C) * 0.2)\n",
114
+ " return out\n",
115
+ "\n",
116
+ "\n",
117
+ "sine_samples = generate_sine(N)\n",
118
+ "spike_samples = generate_spike(N)\n",
119
+ "trend_samples = generate_trend(N)\n",
120
+ "\n",
121
+ "all_samples = sine_samples + spike_samples + trend_samples\n",
122
+ "all_labels = [\"sine\"] * N + [\"spike\"] * N + [\"trend\"] * N"
123
+ ]
124
+ },
125
+ {
126
+ "cell_type": "code",
127
+ "execution_count": null,
128
+ "metadata": {},
129
+ "outputs": [],
130
+ "source": [
131
+ "fig, axes = plt.subplots(1, 3, figsize=(12, 2.2))\n",
132
+ "for ax, (sample, name) in zip(axes, [(sine_samples[0], \"sine\"), (spike_samples[0], \"spike\"), (trend_samples[0], \"trend\")]):\n",
133
+ " ax.plot(sample[:, 0], color=PALETTE[name], linewidth=1.0, alpha=0.9)\n",
134
+ " ax.plot(sample[:, 1], color=PALETTE[name], linewidth=1.0, alpha=0.5)\n",
135
+ " ax.set_title(name)\n",
136
+ " ax.set_xlim(0, T)\n",
137
+ " ax.tick_params(left=False, labelleft=False)\n",
138
+ "plt.tight_layout(pad=1.5)\n",
139
+ "plt.show()"
140
+ ]
141
+ },
142
+ {
143
+ "cell_type": "code",
144
+ "execution_count": null,
145
+ "metadata": {},
146
+ "outputs": [],
147
+ "source": [
148
+ "response = client.embeddings.create(\n",
149
+ " descriptions=[[\"ch_0\", \"ch_1\"]] * len(all_samples),\n",
150
+ " ts_array=[s.tolist() for s in all_samples],\n",
151
+ " return_tensors=\"np\",\n",
152
+ " batch_size=32,\n",
153
+ ")\n",
154
+ "# Aggregate (N, n_patches, C, D) → (N, D) by mean over patches and channels.\n",
155
+ "raw = response.embeds\n",
156
+ "if raw.ndim == 4:\n",
157
+ " embeddings = raw.mean(axis=(1, 2))\n",
158
+ "else:\n",
159
+ " embeddings = raw\n",
160
+ "print(f\"{embeddings.shape[0]} samples → {embeddings.shape[1]}-d embeddings\")\n"
161
+ ]
162
+ },
163
+ {
164
+ "cell_type": "markdown",
165
+ "metadata": {},
166
+ "source": [
167
+ "### t-SNE"
168
+ ]
169
+ },
170
+ {
171
+ "cell_type": "code",
172
+ "execution_count": null,
173
+ "metadata": {},
174
+ "outputs": [],
175
+ "source": [
176
+ "tsne = TSNE(n_components=2, perplexity=5, random_state=0, init=\"pca\", learning_rate=\"auto\")\n",
177
+ "coords = tsne.fit_transform(embeddings)\n",
178
+ "\n",
179
+ "fig, ax = plt.subplots(figsize=(5, 4))\n",
180
+ "for label in [\"sine\", \"spike\", \"trend\"]:\n",
181
+ " mask = np.array(all_labels) == label\n",
182
+ " ax.scatter(\n",
183
+ " coords[mask, 0], coords[mask, 1],\n",
184
+ " c=PALETTE[label], s=70, label=label,\n",
185
+ " edgecolors=\"white\", linewidth=0.6, zorder=3,\n",
186
+ " )\n",
187
+ "ax.set_xticks([])\n",
188
+ "ax.set_yticks([])\n",
189
+ "ax.legend(frameon=True, fontsize=9, loc=\"best\", framealpha=0.9)\n",
190
+ "ax.set_title(\"CHARM embeddings — t-SNE\")\n",
191
+ "plt.tight_layout()\n",
192
+ "plt.show()"
193
+ ]
194
+ },
195
+ {
196
+ "cell_type": "markdown",
197
+ "metadata": {},
198
+ "source": [
199
+ "### Retrieval accuracy\n",
200
+ "\n",
201
+ "One sample per class as query → retrieve from the remaining corpus."
202
+ ]
203
+ },
204
+ {
205
+ "cell_type": "code",
206
+ "execution_count": null,
207
+ "metadata": {},
208
+ "outputs": [],
209
+ "source": [
210
+ "query_idx = [0, N, 2 * N]\n",
211
+ "corpus_idx = [i for i in range(len(all_samples)) if i not in query_idx]\n",
212
+ "\n",
213
+ "metrics = retrieval_metrics(\n",
214
+ " query_emb=embeddings[query_idx],\n",
215
+ " corpus_emb=embeddings[corpus_idx],\n",
216
+ " query_labels=[all_labels[i] for i in query_idx],\n",
217
+ " corpus_labels=[all_labels[i] for i in corpus_idx],\n",
218
+ " k_values=[1, 3, 5],\n",
219
+ ")\n",
220
+ "\n",
221
+ "ks = [1, 3, 5]\n",
222
+ "hits = [metrics[f\"hit_rate@{k}\"] for k in ks]\n",
223
+ "precs = [metrics[f\"precision@{k}\"] for k in ks]\n",
224
+ "\n",
225
+ "fig, ax = plt.subplots(figsize=(4.5, 2.8))\n",
226
+ "x = np.arange(len(ks))\n",
227
+ "w = 0.35\n",
228
+ "ax.bar(x - w/2, hits, w, label=\"hit rate\", color=\"#1976D2\", edgecolor=\"white\", linewidth=0.5)\n",
229
+ "ax.bar(x + w/2, precs, w, label=\"precision\", color=\"#90CAF9\", edgecolor=\"white\", linewidth=0.5)\n",
230
+ "ax.set_xticks(x)\n",
231
+ "ax.set_xticklabels([f\"@{k}\" for k in ks])\n",
232
+ "ax.set_ylim(0, 1.15)\n",
233
+ "ax.set_ylabel(\"score\")\n",
234
+ "ax.set_title(\"Retrieval quality\")\n",
235
+ "ax.legend(fontsize=8, loc=\"upper right\")\n",
236
+ "for i, (h, p) in enumerate(zip(hits, precs)):\n",
237
+ " ax.text(i - w/2, h + 0.03, f\"{h:.0%}\", ha=\"center\", fontsize=8)\n",
238
+ " ax.text(i + w/2, p + 0.03, f\"{p:.0%}\", ha=\"center\", fontsize=8)\n",
239
+ "plt.tight_layout()\n",
240
+ "plt.show()"
241
+ ]
242
+ },
243
+ {
244
+ "cell_type": "markdown",
245
+ "metadata": {},
246
+ "source": [
247
+ "---\n",
248
+ "## 2 — Anomaly Detection\n",
249
+ "\n",
250
+ "3-channel sinusoidal signal. First half is clean (reference), second half has 3 injected anomaly bursts. \n",
251
+ "Three approaches:\n",
252
+ "- **A** — kNN distance on windowed embeddings \n",
253
+ "- **B** — Reconstruction error (zero-shot backcast)\n",
254
+ "- **C** — Forecast error (zero-shot forecast vs actual)"
255
+ ]
256
+ },
257
+ {
258
+ "cell_type": "code",
259
+ "execution_count": null,
260
+ "metadata": {
261
+ "jupyter": {
262
+ "source_hidden": true
263
+ },
264
+ "tags": [
265
+ "hide-input"
266
+ ]
267
+ },
268
+ "outputs": [],
269
+ "source": [
270
+ "np.random.seed(123)\n",
271
+ "\n",
272
+ "# Synthetic AD setup: long mostly-normal series + 3 short anomaly bursts.\n",
273
+ "# Normal density much higher than anomaly density to mimic real-world AD.\n",
274
+ "T_TOTAL = 2000\n",
275
+ "CHANNELS = [\"sensor_A\", \"sensor_B\", \"sensor_C\"]\n",
276
+ "C_AD = len(CHANNELS)\n",
277
+ "\n",
278
+ "t = np.linspace(0, 16 * np.pi, T_TOTAL)\n",
279
+ "series = np.stack([np.sin(t), np.cos(t * 0.7), np.sin(t * 1.3 + 1)], axis=-1)\n",
280
+ "series += np.random.randn(T_TOTAL, C_AD) * 0.05\n",
281
+ "\n",
282
+ "gt = np.zeros(T_TOTAL, dtype=np.int8)\n",
283
+ "# 3 short anomaly bursts in the test half (60 / 1000 = 6 % anomaly density).\n",
284
+ "anomaly_intervals = [(1100, 1120), (1500, 1520), (1900, 1920)]\n",
285
+ "for s, e in anomaly_intervals:\n",
286
+ " series[s:e] += np.random.randn(e - s, C_AD) * 3.0\n",
287
+ " gt[s:e] = 1\n",
288
+ "\n",
289
+ "SPLIT = 1000 # first half = clean train; second half = test with anomalies\n",
290
+ "WINDOW = 64\n",
291
+ "STRIDE = 8\n",
292
+ "\n",
293
+ "train_s = series[:SPLIT]\n",
294
+ "test_s = series[SPLIT:]\n",
295
+ "test_gt = gt[SPLIT:]\n"
296
+ ]
297
+ },
298
+ {
299
+ "cell_type": "code",
300
+ "execution_count": null,
301
+ "metadata": {},
302
+ "outputs": [],
303
+ "source": [
304
+ "ch_colors = [\"#1565C0\", \"#EF6C00\", \"#2E7D32\"]\n",
305
+ "\n",
306
+ "fig, axes = plt.subplots(2, 1, figsize=(12, 3.5))\n",
307
+ "for c in range(C_AD):\n",
308
+ " axes[0].plot(train_s[:, c], color=ch_colors[c], linewidth=0.7, alpha=0.85)\n",
309
+ "axes[0].set_title(\"Train (clean)\")\n",
310
+ "axes[0].set_xlim(0, SPLIT)\n",
311
+ "axes[0].tick_params(labelbottom=False)\n",
312
+ "\n",
313
+ "for c in range(C_AD):\n",
314
+ " axes[1].plot(test_s[:, c], color=ch_colors[c], linewidth=0.7, alpha=0.85)\n",
315
+ "for s, e in anomaly_intervals:\n",
316
+ " axes[1].axvspan(s - SPLIT, e - SPLIT, color=\"#E53935\", alpha=0.12)\n",
317
+ "axes[1].set_title(\"Test (anomalies highlighted)\")\n",
318
+ "axes[1].set_xlim(0, len(test_s))\n",
319
+ "axes[1].set_xlabel(\"timestep\")\n",
320
+ "plt.tight_layout(pad=1.0)\n",
321
+ "plt.show()"
322
+ ]
323
+ },
324
+ {
325
+ "cell_type": "markdown",
326
+ "metadata": {},
327
+ "source": [
328
+ "### A — kNN distance"
329
+ ]
330
+ },
331
+ {
332
+ "cell_type": "code",
333
+ "execution_count": null,
334
+ "metadata": {},
335
+ "outputs": [],
336
+ "source": [
337
+ "train_emb = sliding_window_embeddings(client, train_s, CHANNELS, window_size=WINDOW, stride=STRIDE, batch_size=32)\n",
338
+ "test_emb = sliding_window_embeddings(client, test_s, CHANNELS, window_size=WINDOW, stride=STRIDE, batch_size=32)\n",
339
+ "\n",
340
+ "knn_scores = knn_anomaly_scores(test_emb, train_emb, k=5, distance=\"cosine\", aggregation=\"mean\")\n",
341
+ "knn_pw = window_scores_to_pointwise(knn_scores, WINDOW, STRIDE, len(test_s), method=\"mean\")\n",
342
+ "knn_pw = (knn_pw - knn_pw.min()) / (knn_pw.max() - knn_pw.min() + 1e-8)"
343
+ ]
344
+ },
345
+ {
346
+ "cell_type": "markdown",
347
+ "metadata": {},
348
+ "source": [
349
+ "> **`window_scores_to_pointwise` methods:**\n",
350
+ "> Each timestep is covered by multiple overlapping windows. The `method` controls how to assign a single score:\n",
351
+ ">\n",
352
+ "> | Method | Behavior | Use case |\n",
353
+ "> |--------|----------|----------|\n",
354
+ "> | `\"mean\"` | Average of all windows covering the point | Smooth, best for offline evaluation |\n",
355
+ "> | `\"max\"` | Max score among covering windows | Conservative, catches isolated spikes |\n",
356
+ "> | `\"last\"` | Score of the most recently *completed* window | Online/streaming — score updates only when a window finishes |\n",
357
+ "> | `\"center\"` | Score of the window centered on the point | Minimal time-shift, tightest alignment |"
358
+ ]
359
+ },
360
+ {
361
+ "cell_type": "markdown",
362
+ "metadata": {},
363
+ "source": [
364
+ "### B — Reconstruction error (backcast)"
365
+ ]
366
+ },
367
+ {
368
+ "cell_type": "code",
369
+ "execution_count": null,
370
+ "metadata": {},
371
+ "outputs": [],
372
+ "source": [
373
+ "n_win = (len(test_s) - WINDOW) // STRIDE + 1\n",
374
+ "test_windows = [test_s[i * STRIDE : i * STRIDE + WINDOW] for i in range(n_win)]\n",
375
+ "\n",
376
+ "bc = client.prediction.create(\n",
377
+ " descriptions=[CHANNELS] * n_win,\n",
378
+ " ts_array=[w.tolist() for w in test_windows],\n",
379
+ " target_len=-WINDOW,\n",
380
+ " return_tensors=\"np\",\n",
381
+ " batch_size=16,\n",
382
+ ")\n",
383
+ "\n",
384
+ "recon = bc.median\n",
385
+ "recon_err = ((recon - np.array(test_windows)) ** 2).mean(axis=(1, 2))\n",
386
+ "recon_pw = window_scores_to_pointwise(recon_err, WINDOW, STRIDE, len(test_s), method=\"mean\")\n",
387
+ "recon_pw = (recon_pw - recon_pw.min()) / (recon_pw.max() - recon_pw.min() + 1e-8)"
388
+ ]
389
+ },
390
+ {
391
+ "cell_type": "markdown",
392
+ "metadata": {},
393
+ "source": [
394
+ "### C — Forecast error\n",
395
+ "\n",
396
+ "Use the preceding window as context, forecast the next `H` steps, compare with actual."
397
+ ]
398
+ },
399
+ {
400
+ "cell_type": "code",
401
+ "execution_count": null,
402
+ "metadata": {},
403
+ "outputs": [],
404
+ "source": [
405
+ "H = 16 # forecast horizon\n",
406
+ "CTX = WINDOW # context length\n",
407
+ "\n",
408
+ "n_fc = (len(test_s) - CTX - H) // STRIDE + 1\n",
409
+ "fc_contexts = [test_s[i * STRIDE : i * STRIDE + CTX] for i in range(n_fc)]\n",
410
+ "fc_targets = [test_s[i * STRIDE + CTX : i * STRIDE + CTX + H] for i in range(n_fc)]\n",
411
+ "\n",
412
+ "fc = client.prediction.create(\n",
413
+ " descriptions=[CHANNELS] * n_fc,\n",
414
+ " ts_array=[ctx.tolist() for ctx in fc_contexts],\n",
415
+ " target_len=H,\n",
416
+ " return_tensors=\"np\",\n",
417
+ " batch_size=16,\n",
418
+ ")\n",
419
+ "\n",
420
+ "fc_pred = fc.median # (n_fc, H, C)\n",
421
+ "fc_actual = np.array(fc_targets) # (n_fc, H, C)\n",
422
+ "fc_err = ((fc_pred - fc_actual) ** 2).mean(axis=(1, 2))\n",
423
+ "\n",
424
+ "fc_pw = window_scores_to_pointwise(fc_err, CTX + H, STRIDE, len(test_s), method=\"mean\")\n",
425
+ "fc_pw = (fc_pw - fc_pw.min()) / (fc_pw.max() - fc_pw.min() + 1e-8)"
426
+ ]
427
+ },
428
+ {
429
+ "cell_type": "markdown",
430
+ "metadata": {},
431
+ "source": [
432
+ "### D — Zero-shot recipes (no labels, no train split)\n",
433
+ "\n",
434
+ "Score the **test embeddings directly** — no labels, no train split.\n",
435
+ "\n",
436
+ "1. **IsolationForest** — `sklearn.ensemble.IsolationForest`. One line.\n",
437
+ "2. **CBLOF** — `pyod.models.cblof.CBLOF` (optional dep).\n",
438
+ "3. **Bootstrap k-NN** — two steps inline: IsolationForest picks the bottom 70% as a clean reference, then `knn_anomaly_scores` distance to that reference. **Best of the three on TSB-AD-M.**\n",
439
+ "\n",
440
+ "We L2-normalize embeddings before scoring so all three share the supervised k-NN cell's cosine geometry.\n"
441
+ ]
442
+ },
443
+ {
444
+ "cell_type": "code",
445
+ "execution_count": null,
446
+ "metadata": {},
447
+ "outputs": [],
448
+ "source": [
449
+ "# Run three zero-shot recipes on the TEST embeddings directly.\n",
450
+ "# We L2-normalize so distances are equivalent to cosine — matching the\n",
451
+ "# supervised k-NN cell's geometry.\n",
452
+ "from charm_toolkit.retrieval import l2_normalize\n",
453
+ "test_emb_n = l2_normalize(test_emb)\n",
454
+ "\n",
455
+ "# D1 — IsolationForest: 1-line sklearn.\n",
456
+ "if_scores = -IsolationForest(n_estimators=200, random_state=0).fit(test_emb_n).score_samples(test_emb_n)\n",
457
+ "\n",
458
+ "# D2 — CBLOF (pyod): skipped if pyod unavailable or CBLOF can't split clusters.\n",
459
+ "try:\n",
460
+ " from pyod.models.cblof import CBLOF\n",
461
+ " cb_scores = CBLOF(n_clusters=4, random_state=0).fit(test_emb_n).decision_scores_\n",
462
+ " have_cblof = True\n",
463
+ "except (ImportError, ValueError) as e:\n",
464
+ " cb_scores, have_cblof = None, False\n",
465
+ " print(f\"CBLOF skipped: {type(e).__name__}: {e}\")\n",
466
+ "\n",
467
+ "# D3 — Bootstrap k-NN: 2 steps. Pick a clean reference with IsolationForest;\n",
468
+ "# score every window by k-NN distance to that reference.\n",
469
+ "suspicion = -IsolationForest(n_estimators=200, random_state=0).fit(test_emb_n).score_samples(test_emb_n)\n",
470
+ "reference = test_emb_n[suspicion <= np.quantile(suspicion, 0.70)]\n",
471
+ "bs_scores = knn_anomaly_scores(test_emb_n, reference, k=1, distance=\"cosine\")\n",
472
+ "\n",
473
+ "def to_pw(s):\n",
474
+ " pw = window_scores_to_pointwise(s, WINDOW, STRIDE, len(test_s), method=\"mean\")\n",
475
+ " return (pw - pw.min()) / (pw.max() - pw.min() + 1e-8)\n",
476
+ "\n",
477
+ "if_pw = to_pw(if_scores)\n",
478
+ "cb_pw = to_pw(cb_scores) if have_cblof else None\n",
479
+ "bs_pw = to_pw(bs_scores)\n",
480
+ "print(f\"IsolationForest scores shape={if_pw.shape}, range=[{if_pw.min():.3f}, {if_pw.max():.3f}]\")\n",
481
+ "if have_cblof:\n",
482
+ " print(f\"CBLOF scores shape={cb_pw.shape}, range=[{cb_pw.min():.3f}, {cb_pw.max():.3f}]\")\n",
483
+ "print(f\"Bootstrap k-NN scores shape={bs_pw.shape}, range=[{bs_pw.min():.3f}, {bs_pw.max():.3f}]\")\n"
484
+ ]
485
+ },
486
+ {
487
+ "cell_type": "code",
488
+ "execution_count": null,
489
+ "metadata": {},
490
+ "outputs": [],
491
+ "source": [
492
+ "# Plot zero-shot anomaly scores alongside the test signal & ground truth.\n",
493
+ "panels = [\n",
494
+ " (\"D1 — IsolationForest (sklearn)\", if_pw, \"#01579B\", \"#4FC3F7\"),\n",
495
+ " (\"D3 — Bootstrap k-NN (charm_toolkit)\", bs_pw, \"#1B5E20\", \"#66BB6A\"),\n",
496
+ "]\n",
497
+ "if have_cblof:\n",
498
+ " panels.insert(1, (\"D2 — CBLOF (pyod)\", cb_pw, \"#6A1B9A\", \"#BA68C8\"))\n",
499
+ "\n",
500
+ "fig, axes = plt.subplots(len(panels) + 1, 1, figsize=(12, 1.7 * (len(panels) + 1)),\n",
501
+ " sharex=True, gridspec_kw={\"hspace\": 0.35})\n",
502
+ "\n",
503
+ "axes[0].plot(test_s[:, 0], color=\"#455A64\", linewidth=0.7)\n",
504
+ "for s, e in anomaly_intervals:\n",
505
+ " axes[0].axvspan(s - SPLIT, e - SPLIT, color=\"#E53935\", alpha=0.12)\n",
506
+ "axes[0].set_ylabel(\"value\"); axes[0].set_title(\"Test signal (sensor_A)\")\n",
507
+ "\n",
508
+ "for ax, (title, pw, line_c, fill_c) in zip(axes[1:], panels):\n",
509
+ " ax.fill_between(range(len(pw)), pw, color=fill_c, alpha=0.35)\n",
510
+ " ax.plot(pw, color=line_c, linewidth=1.0)\n",
511
+ " for s, e in anomaly_intervals:\n",
512
+ " ax.axvspan(s - SPLIT, e - SPLIT, color=\"#E53935\", alpha=0.10)\n",
513
+ " ax.set_ylabel(\"score\"); ax.set_title(title); ax.set_ylim(-0.05, 1.05)\n",
514
+ "axes[-1].set_xlabel(\"timestep (test)\")\n",
515
+ "fig.suptitle(\"Zero-shot anomaly detection on test embeddings (red = ground-truth anomalies)\", y=0.995)\n",
516
+ "plt.tight_layout()\n",
517
+ "plt.show()\n"
518
+ ]
519
+ },
520
+ {
521
+ "cell_type": "markdown",
522
+ "metadata": {},
523
+ "source": [
524
+ "### Results"
525
+ ]
526
+ },
527
+ {
528
+ "cell_type": "code",
529
+ "execution_count": null,
530
+ "metadata": {},
531
+ "outputs": [],
532
+ "source": [
533
+ "methods = [\n",
534
+ " (\"A — kNN distance (supervised)\", knn_pw, \"#E65100\", \"#FF6F00\"),\n",
535
+ " (\"B — Reconstruction (backcast)\", recon_pw, \"#4A148C\", \"#7C4DFF\"),\n",
536
+ " (\"C — Forecast error\", fc_pw, \"#1B5E20\", \"#66BB6A\"),\n",
537
+ " (\"D1 — IsolationForest (zero-shot)\", if_pw, \"#01579B\", \"#4FC3F7\"),\n",
538
+ " (\"D3 — Bootstrap k-NN (zero-shot)\", bs_pw, \"#2E7D32\", \"#A5D6A7\"),\n",
539
+ "]\n",
540
+ "if have_cblof:\n",
541
+ " methods.insert(4, (\"D2 — CBLOF (zero-shot)\", cb_pw, \"#6A1B9A\", \"#BA68C8\"))\n",
542
+ "\n",
543
+ "fig, axes = plt.subplots(len(methods) + 1, 1, figsize=(12, 1.7 * (len(methods) + 1)),\n",
544
+ " sharex=True, gridspec_kw={\"hspace\": 0.35})\n",
545
+ "\n",
546
+ "axes[0].plot(test_s[:, 0], color=\"#455A64\", linewidth=0.7)\n",
547
+ "for s, e in anomaly_intervals:\n",
548
+ " axes[0].axvspan(s - SPLIT, e - SPLIT, color=\"#E53935\", alpha=0.12)\n",
549
+ "axes[0].set_ylabel(\"value\"); axes[0].set_title(\"Test signal (sensor_A)\")\n",
550
+ "\n",
551
+ "for ax, (title, pw, line_c, fill_c) in zip(axes[1:], methods):\n",
552
+ " ax.fill_between(range(len(pw)), pw, color=fill_c, alpha=0.35)\n",
553
+ " ax.plot(pw, color=line_c, linewidth=1.0)\n",
554
+ " for s, e in anomaly_intervals:\n",
555
+ " ax.axvspan(s - SPLIT, e - SPLIT, color=\"#E53935\", alpha=0.10)\n",
556
+ " ax.set_ylabel(\"score\"); ax.set_title(title); ax.set_ylim(-0.05, 1.05)\n",
557
+ "axes[-1].set_xlabel(\"timestep (test)\")\n",
558
+ "plt.tight_layout()\n",
559
+ "plt.show()\n"
560
+ ]
561
+ },
562
+ {
563
+ "cell_type": "markdown",
564
+ "metadata": {},
565
+ "source": [
566
+ "### Quantitative comparison (AUROC + AvgPrec)\n",
567
+ "\n",
568
+ "Compare all six methods on the ground-truth anomaly mask using AUROC and Average Precision. The zero-shot recipes should land at or above the supervised k-NN baseline."
569
+ ]
570
+ },
571
+ {
572
+ "cell_type": "code",
573
+ "execution_count": null,
574
+ "metadata": {},
575
+ "outputs": [],
576
+ "source": [
577
+ "from sklearn.metrics import roc_auc_score, average_precision_score\n",
578
+ "\n",
579
+ "results = [\n",
580
+ " (\"A — kNN distance (supervised)\", knn_pw),\n",
581
+ " (\"B — Reconstruction (backcast)\", recon_pw),\n",
582
+ " (\"C — Forecast error\", fc_pw),\n",
583
+ " (\"D1 — IsolationForest (zero-shot)\", if_pw),\n",
584
+ " (\"D3 — Bootstrap k-NN (zero-shot)\", bs_pw),\n",
585
+ "]\n",
586
+ "if have_cblof:\n",
587
+ " results.insert(4, (\"D2 — CBLOF (zero-shot)\", cb_pw))\n",
588
+ "\n",
589
+ "print(f\"{'Method':<38} {'AUROC':>6} {'AvgPrec':>8} {'anom-clean':>10}\")\n",
590
+ "print(\"-\" * 70)\n",
591
+ "for name, pw in results:\n",
592
+ " auc = roc_auc_score(test_gt, pw)\n",
593
+ " ap = average_precision_score(test_gt, pw)\n",
594
+ " sep = pw[test_gt == 1].mean() - pw[test_gt == 0].mean()\n",
595
+ " print(f\"{name:<38} {auc:>6.3f} {ap:>8.3f} {sep:>+10.3f}\")\n"
596
+ ]
597
+ }
598
+ ],
599
+ "metadata": {
600
+ "kernelspec": {
601
+ "display_name": "Python 3",
602
+ "language": "python",
603
+ "name": "python3"
604
+ },
605
+ "language_info": {
606
+ "codemirror_mode": {
607
+ "name": "ipython",
608
+ "version": 3
609
+ },
610
+ "file_extension": ".py",
611
+ "mimetype": "text/x-python",
612
+ "name": "python",
613
+ "nbconvert_exporter": "python",
614
+ "pygments_lexer": "ipython3",
615
+ "version": "3.11.4"
616
+ }
617
+ },
618
+ "nbformat": 4,
619
+ "nbformat_minor": 4
620
+ }
notebooks/getting_started.ipynb ADDED
@@ -0,0 +1,311 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "# Getting Started with CHARM SDK\n",
8
+ "\n",
9
+ "This notebook demonstrates how to use the CHARM SDK to generate embeddings from time series data."
10
+ ]
11
+ },
12
+ {
13
+ "cell_type": "markdown",
14
+ "metadata": {
15
+ "jp-MarkdownHeadingCollapsed": true
16
+ },
17
+ "source": [
18
+ "## Setup\n",
19
+ "\n",
20
+ "First, let's import the necessary libraries and set up our client:"
21
+ ]
22
+ },
23
+ {
24
+ "cell_type": "code",
25
+ "execution_count": null,
26
+ "metadata": {},
27
+ "outputs": [],
28
+ "source": [
29
+ "import os\n",
30
+ "import numpy as np\n",
31
+ "from dotenv import load_dotenv\n",
32
+ "import matplotlib.pyplot as plt\n",
33
+ "from charm import CharmClient\n",
34
+ "import asyncio\n",
35
+ "import nest_asyncio\n",
36
+ "nest_asyncio.apply()\n",
37
+ "\n",
38
+ "# Create a client\n",
39
+ "# Initialize the CHARM client using environment variables\n",
40
+ "\n",
41
+ "\n",
42
+ "# # Load environment variables from .env file\n",
43
+ "# load_dotenv()\n",
44
+ "\n",
45
+ "# # Get API key and base URL from environment variables\n",
46
+ "# api_key = os.getenv(\"CHARM_API_KEY\", \"sk-charm-default-key\")\n",
47
+ "# base_url = os.getenv(\"CHARM_BASE_URL\", \"EMBEDDING_MODEL_ENDPOINT\")\n",
48
+ "\n",
49
+ "api_key = \"sk-charm-default-key\"\n",
50
+ "base_url = \"EMBEDDING_MODEL_ENDPOINT\"\n",
51
+ "\n",
52
+ "client = CharmClient(\n",
53
+ " base_url=base_url,\n",
54
+ " api_key=api_key\n",
55
+ ")\n"
56
+ ]
57
+ },
58
+ {
59
+ "cell_type": "markdown",
60
+ "metadata": {
61
+ "jp-MarkdownHeadingCollapsed": true
62
+ },
63
+ "source": [
64
+ "## Generate Synthetic Time Series Data\n",
65
+ "\n",
66
+ "Let's create some synthetic time series data to demonstrate the SDK:"
67
+ ]
68
+ },
69
+ {
70
+ "cell_type": "code",
71
+ "execution_count": null,
72
+ "metadata": {},
73
+ "outputs": [],
74
+ "source": [
75
+ "def generate_sine_wave(freq, amplitude, phase, length=100):\n",
76
+ " \"\"\"Generate a sine wave.\"\"\"\n",
77
+ " t = np.linspace(0, 10, length)\n",
78
+ " return amplitude * np.sin(2 * np.pi * freq * t + phase)\n",
79
+ "\n",
80
+ "# Generate 3 different time series\n",
81
+ "np.random.seed(42)\n",
82
+ "ts1 = np.column_stack([\n",
83
+ " generate_sine_wave(0.5, 1.0, 0), # Temperature\n",
84
+ " generate_sine_wave(0.2, 2.0, np.pi/4) # Pressure\n",
85
+ "])\n",
86
+ "\n",
87
+ "# phase shift with respect to ts1\n",
88
+ "ts2 = np.column_stack([\n",
89
+ " generate_sine_wave(0.5, 1.5, np.pi/2), # Temperature\n",
90
+ " generate_sine_wave(0.2, 2.5, np.pi/3) # Pressure\n",
91
+ "])\n",
92
+ "\n",
93
+ "# amplitude change with respect to ts2\n",
94
+ "ts3 = np.column_stack([\n",
95
+ " generate_sine_wave(0.5, 10, np.pi/2), # Temperature\n",
96
+ " generate_sine_wave(0.2, 20, np.pi/3) # Pressure\n",
97
+ "])\n",
98
+ "\n",
99
+ "# amplitude, phase, frequency change wrt to all\n",
100
+ "ts4 = np.column_stack([\n",
101
+ " generate_sine_wave(0.7, 0.8, np.pi), # Temperature\n",
102
+ " generate_sine_wave(0.4, 1.8, np.pi/6) # Pressure\n",
103
+ "])\n",
104
+ "\n",
105
+ "# Add some noise\n",
106
+ "ts1 += np.random.normal(0, 0.1, ts1.shape)\n",
107
+ "ts2 += np.random.normal(0, 0.1, ts2.shape)\n",
108
+ "ts3 += np.random.normal(0, 0.1, ts3.shape)\n",
109
+ "ts4 += np.random.normal(0, 0.1, ts4.shape)\n",
110
+ "\n",
111
+ "# Convert to Python lists for the SDK\n",
112
+ "ts_array = [ts1.tolist(), ts2.tolist(), ts3.tolist(), ts4.tolist()]\n",
113
+ "descriptions = [[\"temperature\", \"pressure\"] for _ in range(4)]"
114
+ ]
115
+ },
116
+ {
117
+ "cell_type": "markdown",
118
+ "metadata": {},
119
+ "source": [
120
+ "Let's visualize our synthetic time series data:"
121
+ ]
122
+ },
123
+ {
124
+ "cell_type": "code",
125
+ "execution_count": null,
126
+ "metadata": {},
127
+ "outputs": [],
128
+ "source": [
129
+ "plt.figure(figsize=(15, 10))\n",
130
+ "\n",
131
+ "for i, ts in enumerate([ts1, ts2, ts3, ts4]):\n",
132
+ " plt.subplot(4, 1, i+1)\n",
133
+ " plt.plot(ts[:, 0], label='Temperature')\n",
134
+ " plt.plot(ts[:, 1], label='Pressure')\n",
135
+ " plt.title(f'Time Series {i+1}')\n",
136
+ " plt.legend()\n",
137
+ " plt.grid(True)\n",
138
+ "\n",
139
+ "plt.tight_layout()\n",
140
+ "plt.show()"
141
+ ]
142
+ },
143
+ {
144
+ "cell_type": "markdown",
145
+ "metadata": {
146
+ "jp-MarkdownHeadingCollapsed": true
147
+ },
148
+ "source": [
149
+ "## Generate Embeddings\n",
150
+ "\n",
151
+ "Now, let's use the CHARM SDK to generate embeddings for our time series data. It is possible to generate the embeddings in a synchronous and asynchronous manner (recommended). "
152
+ ]
153
+ },
154
+ {
155
+ "cell_type": "code",
156
+ "execution_count": null,
157
+ "metadata": {},
158
+ "outputs": [],
159
+ "source": [
160
+ "async def generate_embeddings():\n",
161
+ " async with CharmClient(timeout=120) as client:\n",
162
+ " response = await client.embeddings.async_create(\n",
163
+ " descriptions=descriptions,\n",
164
+ " ts_array=ts_array,\n",
165
+ " return_tensors=\"np\",\n",
166
+ " max_B_per_request=32,\n",
167
+ " concurrency_per_call=4, \n",
168
+ " )\n",
169
+ " return response\n",
170
+ "\n",
171
+ "# Run the async function\n",
172
+ "response = asyncio.run(generate_embeddings())\n",
173
+ "\n",
174
+ "# Print information about the response\n",
175
+ "print(f\"Model: {response.model}\")\n",
176
+ "print(f\"Embeddings shape: {response.embeds.shape}\")\n",
177
+ "\n",
178
+ "# If there's usage information\n",
179
+ "if response.usage:\n",
180
+ " print(f\"Usage: {response.usage}\")"
181
+ ]
182
+ },
183
+ {
184
+ "cell_type": "markdown",
185
+ "metadata": {
186
+ "jp-MarkdownHeadingCollapsed": true
187
+ },
188
+ "source": [
189
+ "## Visualize Embeddings\n",
190
+ "\n",
191
+ "Let's visualize the embeddings using PCA to reduce them to 2 dimensions:"
192
+ ]
193
+ },
194
+ {
195
+ "cell_type": "code",
196
+ "execution_count": null,
197
+ "metadata": {},
198
+ "outputs": [],
199
+ "source": [
200
+ "from sklearn.decomposition import PCA\n",
201
+ "\n",
202
+ "## Double pooling across time and channels and normalizing\n",
203
+ "double_pooled_normalized_embeddings = response.embeds.mean(axis=1).mean(axis=1).T/np.linalg.norm(response.embeds.mean(axis=1).mean(axis=1), axis=1)\n",
204
+ "double_pooled_normalized_embeddings = double_pooled_normalized_embeddings.T\n",
205
+ "\n",
206
+ "# Reduce dimensions with PCA\n",
207
+ "pca = PCA(n_components=2)\n",
208
+ "embeddings_2d = pca.fit_transform(double_pooled_normalized_embeddings)\n",
209
+ "\n",
210
+ "# Plot\n",
211
+ "plt.figure(figsize=(10, 8))\n",
212
+ "plt.scatter(embeddings_2d[:, 0], embeddings_2d[:, 1], s=100)\n",
213
+ "\n",
214
+ "# Add labels\n",
215
+ "for i, (x, y) in enumerate(embeddings_2d):\n",
216
+ " plt.annotate(f\"TS {i+1}\", (x, y), fontsize=12)\n",
217
+ " \n",
218
+ "plt.title(\"PCA of Time Series Embeddings\")\n",
219
+ "plt.xlabel(\"PC1\")\n",
220
+ "plt.ylabel(\"PC2\")\n",
221
+ "plt.grid(True)\n",
222
+ "plt.show()"
223
+ ]
224
+ },
225
+ {
226
+ "cell_type": "markdown",
227
+ "metadata": {
228
+ "jp-MarkdownHeadingCollapsed": true
229
+ },
230
+ "source": [
231
+ "## Using PyTorch Tensors\n",
232
+ "\n",
233
+ "The CHARM SDK also supports returning embeddings as PyTorch tensors:"
234
+ ]
235
+ },
236
+ {
237
+ "cell_type": "code",
238
+ "execution_count": null,
239
+ "metadata": {},
240
+ "outputs": [],
241
+ "source": [
242
+ "import torch\n",
243
+ "\n",
244
+ "# Generate embeddings as PyTorch tensors\n",
245
+ "response_torch = client.embeddings.create(\n",
246
+ " descriptions=descriptions,\n",
247
+ " ts_array=ts_array,\n",
248
+ " return_tensors=\"torch\" # Get PyTorch tensor\n",
249
+ ")\n",
250
+ "\n",
251
+ "print(f\"PyTorch tensor type: {type(response_torch.embeds)}\")\n",
252
+ "print(f\"PyTorch tensor shape: {response_torch.embeds.shape}\")\n",
253
+ "print(f\"PyTorch tensor device: {response_torch.embeds.device}\")\n",
254
+ "\n",
255
+ "# Calculate cosine similarity between embeddings\n",
256
+ "from torch.nn.functional import normalize\n",
257
+ "\n",
258
+ "## Double pooling across time and channels and normalizing\n",
259
+ "double_pooled_normalized_embeddings = normalize(response_torch.embeds.mean(axis=1).mean(axis=1))\n",
260
+ "\n",
261
+ "# Calculate cosine similarity matrix\n",
262
+ "similarity = torch.mm(double_pooled_normalized_embeddings, double_pooled_normalized_embeddings.t())\n",
263
+ "\n",
264
+ "# Plot similarity matrix\n",
265
+ "plt.figure(figsize=(8, 6))\n",
266
+ "plt.imshow(similarity.cpu().numpy(), cmap='viridis', vmin=0, vmax=1)\n",
267
+ "plt.colorbar(label='Cosine Similarity')\n",
268
+ "plt.title('Cosine Similarity Between Time Series Embeddings')\n",
269
+ "plt.xticks(range(len(ts_array)), [f'TS {i+1}' for i in range(len(ts_array))])\n",
270
+ "plt.yticks(range(len(ts_array)), [f'TS {i+1}' for i in range(len(ts_array))])\n",
271
+ "\n",
272
+ "# Add text annotations\n",
273
+ "for i in range(len(ts_array)):\n",
274
+ " for j in range(len(ts_array)):\n",
275
+ " plt.text(j, i, f'{similarity[i, j]:.2f}', \n",
276
+ " ha='center', va='center', color='white' if similarity[i, j] < 0.7 else 'black')\n",
277
+ "\n",
278
+ "plt.tight_layout()\n",
279
+ "plt.show()"
280
+ ]
281
+ },
282
+ {
283
+ "cell_type": "code",
284
+ "execution_count": null,
285
+ "metadata": {},
286
+ "outputs": [],
287
+ "source": []
288
+ }
289
+ ],
290
+ "metadata": {
291
+ "kernelspec": {
292
+ "display_name": "charm",
293
+ "language": "python",
294
+ "name": "charm"
295
+ },
296
+ "language_info": {
297
+ "codemirror_mode": {
298
+ "name": "ipython",
299
+ "version": 3
300
+ },
301
+ "file_extension": ".py",
302
+ "mimetype": "text/x-python",
303
+ "name": "python",
304
+ "nbconvert_exporter": "python",
305
+ "pygments_lexer": "ipython3",
306
+ "version": "3.11.13"
307
+ }
308
+ },
309
+ "nbformat": 4,
310
+ "nbformat_minor": 4
311
+ }