AnoushkaVyas gerar444 commited on
Commit
0bc036b
Β·
1 Parent(s): c662d51

Add c3-charm SDK usage section (install, client, forecast, embeddings) (#1)

Browse files

- Add c3-charm SDK usage section (install, client, forecast, embeddings) (9008e7dab42012fca2ba7118e8fe285d576a1753)
- Expand model card with full c3-charm SDK documentation (53df55bbffd3f20d4b57f1e685e01051a0d1cfb2)


Co-authored-by: Gerardo Pastrana <gerar444@users.noreply.huggingface.co>

Files changed (1) hide show
  1. README.md +662 -3
README.md CHANGED
@@ -17,9 +17,10 @@ It produces full quantile forecasts (99 quantile levels) for arbitrary horizons
17
  is evaluated zero-shot on the [GIFT-Eval](https://huggingface.co/spaces/Salesforce/GIFT-Eval)
18
  benchmark β€” no GIFT-Eval data is used in training.
19
 
20
- > **Availability:** CHARM is currently **closed-source**. Model weights and
21
- > inference/replication code are not publicly released at this time. This page
22
- > serves as the model card and benchmark record.
 
23
 
24
  ## Model summary
25
 
@@ -40,6 +41,664 @@ Zero-shot probabilistic forecasting of univariate and multivariate time series
40
  across domains (energy, transport, sales, healthcare, nature, web/cloud-ops,
41
  econ/finance). The model is applied without any per-dataset fine-tuning.
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  ## GIFT-Eval results
44
 
45
  Evaluated zero-shot on the full GIFT-Eval benchmark (97 dataset/frequency/term
 
17
  is evaluated zero-shot on the [GIFT-Eval](https://huggingface.co/spaces/Salesforce/GIFT-Eval)
18
  benchmark β€” no GIFT-Eval data is used in training.
19
 
20
+ > **Availability:** CHARM model weights and training/replication code are
21
+ > **not publicly released**. The model is served behind an API and consumed
22
+ > through the open-source **`c3-charm` Python SDK** (see [Using CHARM](#using-charm--the-c3-charm-sdk) below).
23
+ > This page serves as the model card and benchmark record.
24
 
25
  ## Model summary
26
 
 
41
  across domains (energy, transport, sales, healthcare, nature, web/cloud-ops,
42
  econ/finance). The model is applied without any per-dataset fine-tuning.
43
 
44
+ ## Using CHARM β€” the `c3-charm` SDK
45
+
46
+ CHARM is served behind an API and consumed through the open-source [`c3-charm`](https://pypi.org/project/c3-charm/) Python SDK. The SDK provides **embeddings** (multivariate time series β†’ vectors), **forecast/backcast** (quantile predictions), and an optional **toolkit** for downstream tasks (anomaly detection, retrieval, classification, reconstruction, forecasting). The reference below is the full SDK documentation.
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
53
+ pip install c3-charm # core SDK only (embeddings + forecast)
54
+ pip install c3-charm[toolkit] # includes PyTorch models, datasets, trainers
55
+ ```
56
+
57
+ Or from source:
58
+
59
+ ```bash
60
+ git clone https://github.com/c3ai/c3-charm.git
61
+ cd c3-charm
62
+ poetry install # core SDK only
63
+ poetry install --with toolkit # include toolkit dependencies
64
+ ```
65
+
66
+ #### CPU vs CUDA torch
67
+
68
+ `pip install c3-charm` pulls the **default torch wheel from PyPI**, which on
69
+ Linux is the full **CUDA** build (~4 GB). The SDK itself never requires a
70
+ GPU β€” the model runs server-side, and toolkit training heads are small
71
+ enough to fit on CPU β€” so if you want the smaller CPU-only wheel, install
72
+ torch from the CPU index **before** `c3-charm`:
73
+
74
+ ```bash
75
+ pip install torch --index-url https://download.pytorch.org/whl/cpu
76
+ pip install c3-charm
77
+ ```
78
+
79
+ Or from source, use `bash setup.sh`, which forces the CPU wheel.
80
+
81
+ If you instead want a specific CUDA build, install it manually **after**
82
+ `c3-charm`:
83
+
84
+ ```bash
85
+ pip install --force-reinstall --no-deps torch \
86
+ --index-url https://download.pytorch.org/whl/cu124
87
+ ```
88
+
89
+ Swap `cu124` for the CUDA version that matches your driver (`cu121`,
90
+ `cu126`, `cu128`, …). `--no-deps` is important β€” it stops pip from
91
+ re-resolving your torch install.
92
+
93
+ ---
94
+
95
+ ### Core SDK
96
+
97
+ #### Client initialization
98
+
99
+ ```python
100
+ from charm import CharmClient
101
+
102
+ client = CharmClient(
103
+ base_url="http://your-server:8080",
104
+ api_key="your-api-key", # or set CHARM_API_KEY env var
105
+ timeout=300, # override; SDK default is 15s β€” raise it for /forecast (server allows up to ~220s)
106
+ max_retries=3,
107
+ )
108
+ ```
109
+
110
+ #### Embeddings β€” `client.embeddings.create()`
111
+
112
+ Converts time series windows into dense vectors.
113
+
114
+ ```python
115
+ response = client.embeddings.create(
116
+ descriptions=[["sensor_A", "sensor_B"]], # (N, C) channel names
117
+ ts_array=[[[1.0, 2.0], [1.1, 2.1], ...]], # (N, T, C) values
118
+ batch_size=32,
119
+ return_tensors="np", # "list", "np", or "torch"
120
+ aggregate=True, # True β†’ (N, D); False β†’ (N, T_, C, D)
121
+ progress=True,
122
+ )
123
+ embeddings = response.embeds # shape (N, D) when aggregate=True
124
+ ```
125
+
126
+ **`aggregate` parameter:**
127
+ - `True` (default): Returns flattened embeddings `(N, D)` β€” one vector per series. Best for retrieval, classification, clustering.
128
+ - `False`: Returns per-patch, per-channel embeddings `(N, T_, C, D)` where `T_ = ceil(T / patch_size)`. Best for fine-grained tasks or custom heads.
129
+
130
+ **Async** (faster for large datasets):
131
+
132
+ ```python
133
+ response = await client.embeddings.async_create(
134
+ descriptions=descriptions,
135
+ ts_array=ts_array,
136
+ max_B_per_request=32,
137
+ concurrency_per_call=8,
138
+ return_tensors="np",
139
+ aggregate=True,
140
+ )
141
+ ```
142
+
143
+ #### Forecast / Backcast β€” `client.prediction.create()`
144
+
145
+ Zero-shot quantile predictions β€” no training required.
146
+
147
+ ```python
148
+ response = client.prediction.create(
149
+ descriptions=[["sensor_A", "sensor_B"]],
150
+ ts_array=[[[1.0, 2.0], [1.1, 2.1], ...]],
151
+ target_len=10, # positive = forecast, negative = backcast
152
+ return_tensors="np",
153
+ )
154
+ forecast = response.denormalized_predictions # (N, target_len=10, C, Q) β€” Q quantiles (model-dependent, e.g. 21)
155
+ median = response.median # (N, 10, C) β€” point forecast (median quantile)
156
+ ```
157
+
158
+ **Backcast** (reconstruct past values):
159
+
160
+ ```python
161
+ response = client.prediction.create(
162
+ descriptions=descriptions,
163
+ ts_array=ts_array,
164
+ target_len=-8, # reconstruct last 8 steps
165
+ return_tensors="np",
166
+ )
167
+ ```
168
+
169
+ #### Input constraints
170
+
171
+ | Constraint | Limit |
172
+ |---|---|
173
+ | Timesteps per series | 1 ≀ T ≀ 8192 (the model's training window) |
174
+ | Channels per series | No hard limit (large C grows memory ~O(CΒ²) under cross-channel attention) |
175
+ | Per-request size | N Γ— C Γ— T ≀ 500,000 β€” SDK client-side batching guard, not a server limit |
176
+ | Batch consistency | All series in a request must share the same T and C |
177
+ | Best accuracy | T ≀ 8192 (the model's training window); T need not be a multiple of the patch size |
178
+
179
+ The model was trained on windows of up to 8192 timesteps (patch size 16).
180
+ Inputs are not required to be a multiple of the patch size β€” they are padded
181
+ to a patch boundary internally. The architecture can technically accept more
182
+ than 8192 (up to 1500 patches), but longer inputs rely on positions seen only
183
+ in pretraining, so quality degrades. Query `client.model_info()` for the
184
+ served model's actual patch size and embedding dimension.
185
+
186
+ The SDK handles client-side batching automatically when you set `batch_size` (sync) or `max_B_per_request` (async).
187
+
188
+ #### Output shapes
189
+
190
+ | Method | Output field | Shape |
191
+ |---|---|---|
192
+ | `embeddings.create(aggregate=True)` | `response.embeds` | (N, D) |
193
+ | `embeddings.create(aggregate=False)` | `response.embeds` | (N, T_, C, D), T_ = ceil(T / patch_size) |
194
+ | `prediction.create(target_len > 0)` | `response.denormalized_predictions` | (N, target_len, C, Q) |
195
+ | `prediction.create(target_len < 0)` | `response.denormalized_predictions` | (N, abs(target_len), C, Q) |
196
+ | `prediction.create(...)` | `response.median` | (N, abs(target_len), C) |
197
+
198
+ #### Channel descriptions
199
+
200
+ Descriptions are **required** and affect embedding quality. They tell the model what each channel represents.
201
+
202
+ **Good descriptions** β€” use meaningful, consistent names:
203
+ ```python
204
+ descriptions = [["engine_temperature", "oil_pressure", "rpm"]]
205
+ ```
206
+
207
+ **Acceptable** β€” short but informative:
208
+ ```python
209
+ descriptions = [["temp", "pressure", "speed"]]
210
+ ```
211
+
212
+ **Avoid** β€” generic or positional names reduce model effectiveness:
213
+ ```python
214
+ descriptions = [["col_0", "col_1", "col_2"]] # works but suboptimal
215
+ ```
216
+
217
+ When working with pandas DataFrames, use column names directly:
218
+ ```python
219
+ descriptions = [df.columns.tolist()] * N
220
+ ```
221
+
222
+ #### Scaling
223
+
224
+ **No pre-processing needed.** CHARM normalizes internally. Send raw data as-is. Do not apply StandardScaler, MinMaxScaler, or log transforms before calling the API.
225
+
226
+ #### Error handling
227
+
228
+ ```python
229
+ from charm import CharmError, AuthenticationError, InvalidRequestError, RateLimitError
230
+
231
+ try:
232
+ response = client.embeddings.create(...)
233
+ except AuthenticationError:
234
+ # bad API key
235
+ except InvalidRequestError as e:
236
+ # shape violations, empty input
237
+ except RateLimitError:
238
+ # back off and retry
239
+ except CharmError as e:
240
+ # catch-all for other SDK errors
241
+ ```
242
+
243
+ ---
244
+
245
+ ### Toolkit β€” Downstream Tasks
246
+
247
+ The toolkit (`pip install c3-charm[toolkit]`) provides PyTorch models, dataset utilities, and training infrastructure for fine-tuning on top of CHARM embeddings.
248
+
249
+ #### Retrieval β€” `charm_toolkit.retrieval`
250
+
251
+ Find similar time series by embedding similarity.
252
+
253
+ ```python
254
+ from charm_toolkit.retrieval import (
255
+ l2_normalize,
256
+ cosine_similarity_matrix,
257
+ knn_search,
258
+ retrieval_metrics,
259
+ )
260
+
261
+ # Embed your data
262
+ response = client.embeddings.create(
263
+ descriptions=descriptions,
264
+ ts_array=windows_list,
265
+ return_tensors="np",
266
+ )
267
+ embeddings = response.embeds # (N, D)
268
+
269
+ # Similarity search
270
+ sim = cosine_similarity_matrix(embeddings, embeddings)
271
+
272
+ # kNN search
273
+ indices, scores = knn_search(query_emb, corpus_emb, k=5)
274
+
275
+ # Evaluation metrics
276
+ metrics = retrieval_metrics(
277
+ query_emb=query_emb,
278
+ corpus_emb=corpus_emb,
279
+ query_labels=query_labels,
280
+ corpus_labels=corpus_labels,
281
+ k_values=[1, 3, 5, 10],
282
+ exclude_self=True,
283
+ query_ids=query_dataset_names,
284
+ corpus_ids=corpus_dataset_names,
285
+ )
286
+ # Returns: precision@k, ndcg@k, hit_rate@k
287
+ ```
288
+
289
+ #### Anomaly Detection β€” `charm_toolkit.anomaly_detection`
290
+
291
+ Detect anomalies via kNN distance scoring on windowed CHARM embeddings.
292
+
293
+ ```python
294
+ from charm_toolkit.anomaly_detection import (
295
+ sliding_window_embeddings,
296
+ knn_anomaly_scores,
297
+ window_scores_to_pointwise,
298
+ )
299
+
300
+ # 1. Embed sliding windows
301
+ train_emb = sliding_window_embeddings(
302
+ client, train_data, descriptions,
303
+ window_size=128, stride=1, batch_size=64,
304
+ )
305
+ test_emb = sliding_window_embeddings(
306
+ client, test_data, descriptions,
307
+ window_size=128, stride=1, batch_size=64,
308
+ )
309
+
310
+ # 2. Score test windows by distance to train
311
+ window_scores = knn_anomaly_scores(
312
+ test_emb=test_emb,
313
+ reference_emb=train_emb,
314
+ k=5,
315
+ distance="cosine", # "cosine", "l2", "l1"
316
+ aggregation="mean", # "mean", "max"
317
+ )
318
+
319
+ # 3. Aggregate to per-timestep scores
320
+ pointwise_scores = window_scores_to_pointwise(
321
+ window_scores=window_scores,
322
+ window_size=128,
323
+ stride=1,
324
+ total_length=len(test_data),
325
+ method="mean", # "mean", "max", "last", "center"
326
+ )
327
+ ```
328
+
329
+ **Pointwise aggregation methods:**
330
+
331
+ Each timestep is covered by multiple overlapping windows. The `method` parameter controls how to assign a single score per timestep:
332
+
333
+ | Method | Behavior | Use case |
334
+ |--------|----------|----------|
335
+ | `"mean"` | Average of all windows covering the point | Smooth, best for offline evaluation |
336
+ | `"max"` | Max score among covering windows | Conservative, catches isolated spikes |
337
+ | `"last"` | Score of the most recently *completed* window | Online/streaming β€” score only updates when a window finishes processing |
338
+ | `"center"` | Score of the window centered on each point | Minimal time-shift, tightest temporal alignment |
339
+
340
+ **Zero-shot recipes (no clean reference set required) β€” recommended methods, in order of strength:**
341
+
342
+ 1. **Bootstrap k-NN** (best). Two steps: run `sklearn.ensemble.IsolationForest` on the embedding matrix and take the bottom ~70% by score as a presumed-clean reference; then call `knn_anomaly_scores` against that reference.
343
+ 2. **CBLOF**: `pyod.models.cblof.CBLOF` on the embedding matrix.
344
+ 3. **IsolationForest**: `sklearn.ensemble.IsolationForest` directly on the embedding matrix.
345
+
346
+ L2-normalize embeddings beforehand to use cosine geometry. See `demo_retrieval_anomaly_detection.ipynb`.
347
+
348
+ **Best practices (validated on TSB-AD, metric VUS-PR):**
349
+
350
+ 1. **Ensemble the embedding score with per-window statistics.** The encoder
351
+ instance-normalizes each window, erasing amplitude/level-shift anomalies (spikes,
352
+ steps β€” the most common kind) from the embedding. Recover them with
353
+ `window_statistics` (no extra model call) and combine via `ensemble_scores`
354
+ (`method="zscore"` β€” z-score each detector and sum; parameter-free and as strong as a
355
+ tuned weighted ensemble). This is worth **~+7 pp VUS-PR** supervised.
356
+
357
+ ```python
358
+ from charm_toolkit.anomaly_detection import window_statistics, ensemble_scores
359
+
360
+ emb_score = knn_anomaly_scores(test_emb, train_emb, k=3, distance="cosine")
361
+ S_tr = window_statistics(train_data, 128, 1)
362
+ mu, sd = S_tr.mean(0, keepdims=True), S_tr.std(0, keepdims=True) + 1e-8
363
+ stats_score = knn_anomaly_scores(
364
+ (window_statistics(test_data, 128, 1) - mu) / sd, (S_tr - mu) / sd,
365
+ k=3, distance="l2")
366
+ window_scores = ensemble_scores([emb_score, stats_score], method="zscore")
367
+ # zero-shot: build the reference by bootstrap (above) and use method="rank"
368
+ ```
369
+
370
+ 2. **Multivariate: do NOT pool channels when the channel count is high.** Channel-mean
371
+ pooling dilutes an anomaly confined to a few channels across all of them. Keep channels
372
+ separate with `sliding_window_channel_embeddings` + `per_channel_knn_scores`
373
+ (`channel_pool="adaptive"`). The benefit grows with the number of channels β€” negligible
374
+ at C≀3, sizeable at Cβ‰₯20, large at C>60 β€” so it is **advisable whenever C is high**. For
375
+ few channels, plain mean-pooling is equally good and cheaper.
376
+
377
+ ```python
378
+ from charm_toolkit.anomaly_detection import (
379
+ sliding_window_channel_embeddings, per_channel_knn_scores)
380
+
381
+ train_pc = sliding_window_channel_embeddings(client, train_data, descriptions) # (N, C, D)
382
+ test_pc = sliding_window_channel_embeddings(client, test_data, descriptions)
383
+ emb_score = per_channel_knn_scores(test_pc, train_pc, k=3, channel_pool="adaptive")
384
+ ```
385
+
386
+ #### ReconstructionModel β€” anomaly detection via learned head
387
+
388
+ ```python
389
+ from charm_toolkit import (
390
+ ReconstructionModel, create_reconstruction_datasets,
391
+ collator, TrainerClass,
392
+ )
393
+ from torch.utils.data import DataLoader
394
+ import torch.nn as nn
395
+
396
+ train_ds, val_ds, test_ds = create_reconstruction_datasets(
397
+ raw_data, # (T, C) numpy array or torch tensor
398
+ descriptions=channel_names,
399
+ window_size=256,
400
+ stride=1,
401
+ train_ratio=0.7,
402
+ val_ratio=0.15,
403
+ sequential=True,
404
+ scale=True,
405
+ )
406
+
407
+ model = ReconstructionModel(
408
+ embedding_client=client,
409
+ reconstructor="linear", # "linear", "mlp", or custom nn.Module
410
+ hidden_dim=128,
411
+ dropout=0.1,
412
+ )
413
+
414
+ trainer = TrainerClass(
415
+ model=model,
416
+ train_loader=DataLoader(train_ds, batch_size=512, collate_fn=collator),
417
+ val_loader=DataLoader(val_ds, batch_size=512, collate_fn=collator),
418
+ epochs=1000,
419
+ patience=5,
420
+ lr=1e-3,
421
+ criterion=nn.HuberLoss(),
422
+ )
423
+ trainer.fit()
424
+ ```
425
+
426
+ #### ForecastingModel β€” embedding-based forecasting
427
+
428
+ ```python
429
+ from charm_toolkit import ForecastingModel, create_forecasting_datasets, collator, TrainerClass
430
+ from torch.utils.data import DataLoader
431
+
432
+ train_ds, val_ds, test_ds = create_forecasting_datasets(
433
+ raw_data,
434
+ descriptions=channel_names,
435
+ train_horizon=96,
436
+ test_horizon=96,
437
+ train_ratio=0.7,
438
+ val_ratio=0.15,
439
+ sequential=True,
440
+ scale=True,
441
+ )
442
+
443
+ model = ForecastingModel(
444
+ embedding_client=client,
445
+ horizon=96,
446
+ input_size=96,
447
+ head="linear",
448
+ hidden_dim=128,
449
+ mode="last", # "last", "avg", "none"
450
+ per_channel=True,
451
+ num_channels=len(channel_names),
452
+ )
453
+
454
+ trainer = TrainerClass(
455
+ model=model,
456
+ train_loader=DataLoader(train_ds, batch_size=512, collate_fn=collator),
457
+ val_loader=DataLoader(val_ds, batch_size=512, collate_fn=collator),
458
+ epochs=1000,
459
+ patience=10,
460
+ lr=1e-2,
461
+ )
462
+ trainer.fit()
463
+ ```
464
+
465
+ #### ClassificationModel β€” time series classification
466
+
467
+ ```python
468
+ from charm_toolkit import ClassificationModel, create_classification_datasets, collator, TrainerClass
469
+ from torch.utils.data import DataLoader
470
+ import torch.nn as nn
471
+
472
+ train_ds, val_ds, test_ds = create_classification_datasets(
473
+ raw_data, # (N, T, C)
474
+ labels=labels, # list of N integer labels
475
+ descriptions=channel_names,
476
+ train_ratio=0.7,
477
+ val_ratio=0.15,
478
+ )
479
+
480
+ model = ClassificationModel(
481
+ embedding_client=client,
482
+ num_classes=num_classes,
483
+ hidden_dim=128,
484
+ pooling_over_t="mean",
485
+ pooling_over_channels="mean",
486
+ classifier_type="mlp",
487
+ )
488
+
489
+ trainer = TrainerClass(
490
+ model=model,
491
+ train_loader=DataLoader(train_ds, batch_size=32, collate_fn=collator),
492
+ val_loader=DataLoader(val_ds, batch_size=32, collate_fn=collator),
493
+ epochs=100,
494
+ patience=10,
495
+ lr=1e-3,
496
+ criterion=nn.CrossEntropyLoss(),
497
+ )
498
+ trainer.fit()
499
+ ```
500
+
501
+ #### Precomputing embeddings (critical for training)
502
+
503
+ Toolkit models call the API every forward pass. For training with hundreds of windows per epoch, **precompute embeddings once**:
504
+
505
+ ```python
506
+ from charm_toolkit import precompute_dataset_embeddings, PrecomputedEmbeddingsDataset
507
+
508
+ # Compute once, save to disk as memmap
509
+ train_shape = precompute_dataset_embeddings(
510
+ client=client, dataset=train_ds,
511
+ output_path="./outputs/train_embeddings.pt", memory_batch_size=8192
512
+ )
513
+ val_shape = precompute_dataset_embeddings(
514
+ client=client, dataset=val_ds,
515
+ output_path="./outputs/val_embeddings.pt", memory_batch_size=8192
516
+ )
517
+
518
+ # Wrap datasets β€” model skips API calls when "embeds" key present
519
+ train_ds = PrecomputedEmbeddingsDataset(train_ds, "./outputs/train_embeddings.pt", train_shape)
520
+ val_ds = PrecomputedEmbeddingsDataset(val_ds, "./outputs/val_embeddings.pt", val_shape)
521
+
522
+ # Training now uses cached embeddings β€” orders of magnitude faster
523
+ train_loader = DataLoader(train_ds, batch_size=512, shuffle=True, collate_fn=collator)
524
+ ```
525
+
526
+ #### Trainer API
527
+
528
+ ```python
529
+ from charm_toolkit import TrainerClass
530
+
531
+ trainer = TrainerClass(
532
+ model=model,
533
+ train_loader=train_loader,
534
+ val_loader=val_loader,
535
+ test_loader=test_loader, # optional
536
+ lr=1e-3,
537
+ weight_decay=1e-4,
538
+ epochs=1000,
539
+ patience=5,
540
+ min_delta=1e-4,
541
+ max_grad_norm=5.0,
542
+ criterion=None, # defaults to MSELoss
543
+ )
544
+ trainer.fit()
545
+ test_loss = trainer.evaluate(test_loader)
546
+ ```
547
+
548
+ #### Dataset factory functions
549
+
550
+ All return `(train_dataset, val_dataset, test_dataset)`:
551
+
552
+ | Function | Input shape | Key args |
553
+ |---|---|---|
554
+ | `create_reconstruction_datasets(raw_data, ...)` | (T, C) | `window_size`, `stride`, `train_ratio`, `val_ratio` |
555
+ | `create_forecasting_datasets(raw_data, ...)` | (T, C) | `train_horizon`, `test_horizon`, `stride`, `train_ratio`, `val_ratio` |
556
+ | `create_classification_datasets(raw_data, labels, ...)` | (N, T, C) | `train_ratio`, `val_ratio` |
557
+
558
+ Reconstruction and forecasting expect a single long time series `(T, C)` split temporally. Classification expects pre-windowed `(N, T, C)`.
559
+
560
+ #### collator
561
+
562
+ All DataLoaders using toolkit datasets require `collator` as the `collate_fn`:
563
+
564
+ ```python
565
+ from charm_toolkit import collator
566
+ # or equivalently:
567
+ from charm_toolkit.Datasets import collator
568
+ ```
569
+
570
+ ---
571
+
572
+ ### Embeddings as features
573
+
574
+ CHARM embeddings work as drop-in feature vectors for any sklearn model:
575
+
576
+ ```python
577
+ import numpy as np
578
+ from sklearn.ensemble import IsolationForest
579
+ from sklearn.linear_model import LogisticRegression
580
+ from charm_toolkit.retrieval import cosine_similarity_matrix
581
+
582
+ response = client.embeddings.create(
583
+ descriptions=descriptions,
584
+ ts_array=windows_list,
585
+ return_tensors="np",
586
+ )
587
+ X = response.embeds # (N, D)
588
+
589
+ # Anomaly detection with isolation forest
590
+ clf = IsolationForest(contamination=0.05)
591
+ anomaly_labels = clf.fit_predict(X)
592
+
593
+ # Similarity search
594
+ sim = cosine_similarity_matrix(X, X)
595
+
596
+ # As features for any classifier
597
+ clf = LogisticRegression().fit(X_train, y_train)
598
+ ```
599
+
600
+ ---
601
+
602
+ ### Local Deployment
603
+
604
+ Deploy models locally from GitHub releases β€” no remote server needed:
605
+
606
+ ```python
607
+ with CharmClient(tag="experiment-2026-03-15_10-30-00") as client:
608
+ response = client.embeddings.create(...)
609
+ # Server shuts down automatically
610
+ ```
611
+
612
+ When `tag` is provided:
613
+ 1. Checks for GPU availability (falls back to CPU)
614
+ 2. Clones repo at the specified tag (shallow clone)
615
+ 3. Downloads model weights from the GitHub release
616
+ 4. Launches the serving stack locally
617
+ 5. Polls health endpoint until ready
618
+
619
+ Files cached at `~/.charm/models/<tag>/` for fast subsequent runs.
620
+
621
+ ```python
622
+ CharmClient(
623
+ tag="experiment-tag", # required for local mode
624
+ repo_url="https://...", # default: c3-e/research
625
+ cache_dir="/path/to/cache", # default: ~/.charm/models
626
+ port=8080, # 0 = auto-select
627
+ )
628
+ ```
629
+
630
+ ---
631
+
632
+ ### Best practices
633
+
634
+ #### Input & preprocessing
635
+ - Send data as `(N, T, C)` β€” N series, each T timesteps Γ— C channels; all series in one request must share the same T and C.
636
+ - Keep `T ≀ 8192` (the training window). The model can accept more, but quality degrades on lengths it wasn't trained on. T does **not** need to be a multiple of the patch size β€” inputs are padded to a patch boundary internally.
637
+ - **Do not pre-scale** your data. CHARM normalizes internally (asinh z-score); applying StandardScaler/MinMaxScaler/log yourself hurts results.
638
+
639
+ #### Channel descriptions (a real quality lever)
640
+ - Use meaningful, consistent channel names (`"engine_temperature"`, not `"col_0"`) β€” the model is channel-aware and descriptions materially affect embeddings.
641
+ - Reuse the same names across requests so embeddings stay comparable (retrieval, clustering).
642
+
643
+ #### Batching & throughput
644
+ - Respect the per-request budget: `batch_size Γ— C Γ— T ≀ 500,000` (SDK-enforced client-side).
645
+ - Use `async_create` for large N β€” it batches concurrently; the sync client is sequential and slow past ~100 series. Tune `max_B_per_request` / `concurrency_per_call` instead of one giant request.
646
+
647
+ #### Timeouts & retries
648
+ - Raise `timeout` for forecasting β€” the SDK default is 15s, but the server allows forecasts up to ~220s. Use `timeout β‰ˆ 220+` for `prediction.create`.
649
+ - Keep the built-in retries (exponential backoff on 429/5xx) rather than hand-rolling.
650
+
651
+ #### Embeddings
652
+ - `aggregate=True` (default) β†’ `(N, D)` for retrieval / classification / clustering. `aggregate=False` β†’ `(N, T_, C, D)` only when you need per-patch/per-channel detail for a custom head.
653
+ - L2-normalize before cosine similarity (embeddings are unit-normed by the encoder, but normalize again after any pooling you do).
654
+ - Discover `D` at runtime via `client.model_info()` β€” it's model-dependent; don't hardcode.
655
+
656
+ #### Forecasting
657
+ - `target_len > 0` = forecast, `< 0` = backcast; `0` is invalid.
658
+ - Keep `T + abs(target_len) ≀ 8192` (the training window) for best forecast/backcast quality.
659
+ - Use `response.median` for a point forecast, or the full quantile axis for intervals. `Q` is model-dependent (e.g. 21 or 99) β€” read `denormalized_predictions.shape[-1]`.
660
+
661
+ #### Classification
662
+ - For classification heads, **don't pool over channels** β€” keeping the per-channel embeddings flat (rather than averaging them) boosts accuracy, at a modest cost in head size/complexity. In the toolkit `ClassificationModel`, set `pooling_over_channels="flatten"` (and pass `num_channels=C`, required for flatten) instead of the default `"mean"`.
663
+
664
+ #### Dual-model awareness
665
+ - Treat `/predict` (embeddings) and `/forecast` as **separate models** β€” they may have different patch sizes / embedding dims. Read the per-role `models` map from `client.model_info()` instead of assuming they match.
666
+
667
+ #### Training on top of CHARM
668
+ - Precompute embeddings once (`precompute_dataset_embeddings` + `PrecomputedEmbeddingsDataset`) β€” toolkit models otherwise call the API every forward pass.
669
+
670
+ #### Reliability & ops
671
+ - Catch specific errors (`InvalidRequestError`, `AuthenticationError`, `RateLimitError`, or the base `CharmError`) and back off on rate limits.
672
+ - Use the context manager (`with CharmClient(...) as client:`) so local deployments shut down cleanly.
673
+ - Set credentials via env (`CHARM_API_KEY`, `CHARM_BASE_URL`) rather than hardcoding.
674
+
675
+ ---
676
+
677
+ ### Decision guide
678
+
679
+ #### When to use CHARM
680
+
681
+ - Multivariate time series (multiple channels measured over time)
682
+ - Each window has at least a few patches (patch size is 16, so ~48+ timesteps is a good floor)
683
+ - You want a strong starting point without feature engineering
684
+
685
+ #### When to use classical methods instead
686
+
687
+ - Tabular data without a time dimension β€” use LightGBM, XGBoost
688
+ - Very short series (< 10 timesteps)
689
+ - Single scalar features β€” still works but may not outperform ARIMA/ETS
690
+
691
+ #### Zero-shot vs fine-tuned
692
+
693
+ | Approach | When | Effort |
694
+ |---|---|---|
695
+ | `prediction.create(target_len=H)` | Quick forecast baseline, no labeled data | None β€” one API call |
696
+ | Embeddings + sklearn | Moderate data, combine with other features | Minutes |
697
+ | Embeddings + kNN (retrieval/AD) | Unlabeled anomaly detection or search | Minutes |
698
+ | Toolkit model (Reconstruction/Forecasting/Classification) | Have labeled data, want best performance | Train a small head (~minutes on CPU) |
699
+
700
+ ---
701
+
702
  ## GIFT-Eval results
703
 
704
  Evaluated zero-shot on the full GIFT-Eval benchmark (97 dataset/frequency/term