Spaces:
Running on Zero
Running on Zero
| """TimeeClassifier: end-to-end time series classification via in-context learning.""" | |
| from __future__ import annotations | |
| from pathlib import Path | |
| from typing import Callable, Union | |
| import numpy as np | |
| import torch | |
| from sklearn.preprocessing import LabelEncoder | |
| from timee.model.model import TimeeModel | |
| from timee.transforms import default_ensemble_transforms | |
| EnsembleTransform = Callable[[np.ndarray, np.ndarray], tuple[np.ndarray, np.ndarray]] | |
| def _infer_device() -> torch.device: | |
| if torch.cuda.is_available(): | |
| return torch.device("cuda") | |
| if torch.backends.mps.is_available(): | |
| return torch.device("mps") | |
| return torch.device("cpu") | |
| class TimeeClassifier: | |
| """Pretrained TIMEE model for in-context time series classification. | |
| A single forward pass classifies test series given labeled training examples. | |
| No per-dataset training or fine-tuning required. | |
| Usage:: | |
| from timee import TimeeClassifier | |
| clf = TimeeClassifier.from_pretrained("path/to/checkpoint/") | |
| # X arrays: (n_samples, n_channels, seq_len) float32 numpy | |
| predictions, probabilities = clf.predict(X_train, y_train, X_test) | |
| Datasets with more than 10 classes are handled automatically via one-vs-rest (OvR). | |
| """ | |
| def __init__( | |
| self, | |
| model: torch.nn.Module, | |
| device: torch.device, | |
| transforms: list[EnsembleTransform] | None = None, | |
| ) -> None: | |
| self.device = device | |
| self._model = model.to(device) | |
| self._model.eval() | |
| self.transforms = transforms | |
| self.max_classes = getattr(model, "n_max_classes", None) or getattr( | |
| model, "n_classes", None | |
| ) | |
| if self.max_classes is None: | |
| raise ValueError("Model must have an n_max_classes attribute") | |
| def from_pretrained( | |
| cls, | |
| path: Union[str, Path] = "liamsbhoo/timee", | |
| device: Union[str, torch.device, None] = None, | |
| use_ensemble: bool = True, | |
| ) -> "TimeeClassifier": | |
| """Load a pretrained TimeeClassifier. | |
| Args: | |
| path: Local directory containing ``model.safetensors``, or a | |
| HuggingFace Hub repo ID (e.g. ``"liamsbhoo/timee"``). | |
| Defaults to the official checkpoint. | |
| device: Torch device for inference. Defaults to auto-detection (CUDA > MPS > CPU). | |
| use_ensemble: If True (default), use the 4-member preprocessing ensemble from the | |
| paper (interpolate×{256,512} × {raw, first_difference}). | |
| Set to False for faster single-pass inference without preprocessing. | |
| Returns: | |
| A ready-to-use TimeeClassifier. | |
| """ | |
| if device is None: | |
| device = _infer_device() | |
| device = torch.device(device) if isinstance(device, str) else device | |
| local = Path(path) | |
| if local.is_dir(): | |
| weights_path = local / "model.safetensors" | |
| if not weights_path.exists(): | |
| raise FileNotFoundError(f"model.safetensors not found in {local}") | |
| else: | |
| from huggingface_hub import hf_hub_download | |
| weights_path = hf_hub_download(repo_id=str(path), filename="model.safetensors") | |
| from safetensors.torch import load_file | |
| model = TimeeModel() | |
| model.load_state_dict(load_file(weights_path)) | |
| transforms = default_ensemble_transforms() if use_ensemble else None | |
| return cls(model=model, device=device, transforms=transforms) | |
| def _prepare_inputs( | |
| self, | |
| X_train: np.ndarray, | |
| y_train_encoded: np.ndarray, | |
| X_test: np.ndarray, | |
| ) -> tuple[torch.Tensor, torch.Tensor]: | |
| inputs_np = np.concatenate([X_train, X_test], axis=0) | |
| targets_np = np.concatenate( | |
| [y_train_encoded.astype(np.int64), np.zeros(len(X_test), dtype=np.int64)], | |
| axis=0, | |
| ) | |
| if inputs_np.ndim == 3 and inputs_np.shape[1] > 1: | |
| # Multivariate (N, C, T) -> (N, T, C) -> (1, N, T, C) | |
| inputs = ( | |
| torch.from_numpy(inputs_np.transpose(0, 2, 1)).float().to(self.device).unsqueeze(0) | |
| ) | |
| else: | |
| # Univariate (N, 1, T) -> squeeze -> (N, T) -> (1, N, T) | |
| inputs = torch.from_numpy(inputs_np).float().to(self.device).squeeze().unsqueeze(0) | |
| targets = torch.from_numpy(targets_np).long().to(self.device).unsqueeze(0) | |
| return inputs, targets | |
| def _forward( | |
| self, | |
| X_train: np.ndarray, | |
| y_enc: np.ndarray, | |
| X_test_batch: np.ndarray, | |
| n_classes: int, | |
| ) -> np.ndarray: | |
| inputs, targets = self._prepare_inputs(X_train, y_enc, X_test_batch) | |
| logits = self._model(x=inputs, y=targets, eval_pos=len(X_train)).squeeze(0)[:, :n_classes] | |
| return torch.softmax(logits, dim=1).cpu().numpy() | |
| def _forward_ovr( | |
| self, | |
| X_train: np.ndarray, | |
| y_enc: np.ndarray, | |
| X_test_batch: np.ndarray, | |
| n_classes: int, | |
| ) -> np.ndarray: | |
| inputs, targets = self._prepare_inputs(X_train, y_enc, X_test_batch) | |
| eval_pos = len(X_train) | |
| class_probs = [] | |
| for c in range(n_classes): | |
| targets_binary = torch.zeros_like(targets) | |
| targets_binary[:, :eval_pos] = (targets[:, :eval_pos] == c).long() | |
| logits = self._model(x=inputs, y=targets_binary, eval_pos=eval_pos).squeeze(0)[:, :2] | |
| class_probs.append(torch.softmax(logits, dim=1).cpu().numpy()[:, 1]) | |
| probabilities = np.column_stack(class_probs) | |
| probabilities /= probabilities.sum(axis=1, keepdims=True) | |
| return probabilities | |
| def _predict_batched( | |
| self, | |
| X_train: np.ndarray, | |
| y_enc: np.ndarray, | |
| X_test: np.ndarray, | |
| n_classes: int, | |
| batch_fn: Callable, | |
| ) -> np.ndarray: | |
| n_test = len(X_test) | |
| batch_size = n_test | |
| while batch_size >= 1: | |
| try: | |
| chunks = [] | |
| for start in range(0, n_test, batch_size): | |
| chunks.append( | |
| batch_fn(X_train, y_enc, X_test[start : start + batch_size], n_classes) | |
| ) | |
| if self.device.type == "cuda": | |
| torch.cuda.empty_cache() | |
| return np.concatenate(chunks, axis=0) | |
| except torch.cuda.OutOfMemoryError: | |
| torch.cuda.empty_cache() | |
| batch_size //= 2 | |
| raise RuntimeError( | |
| "GPU OOM even with a single test sample -- " | |
| "training context alone exceeds available memory." | |
| ) | |
| def predict( | |
| self, | |
| X_train: np.ndarray, | |
| y_train: np.ndarray, | |
| X_test: np.ndarray, | |
| ) -> tuple[np.ndarray, np.ndarray]: | |
| """Classify test samples given labeled training examples. | |
| Args: | |
| X_train: Training time series, shape (n_train, n_channels, seq_len). | |
| y_train: Training labels, shape (n_train,). Any hashable label type. | |
| X_test: Test time series, shape (n_test, n_channels, seq_len). | |
| Returns: | |
| predictions: Predicted class labels, shape (n_test,). Same dtype as y_train. | |
| probabilities: Class probability estimates, shape (n_test, n_classes). | |
| """ | |
| le = LabelEncoder().fit(y_train) | |
| n_classes = len(le.classes_) | |
| y_enc = le.transform(y_train) | |
| batch_fn = self._forward_ovr if n_classes > self.max_classes else self._forward | |
| x_transforms: list[EnsembleTransform | None] = self.transforms or [None] | |
| with torch.no_grad(): | |
| all_probs = [] | |
| for x_transform in x_transforms: | |
| X_tr_t, X_te_t = x_transform(X_train, X_test) if x_transform else (X_train, X_test) | |
| all_probs.append(self._predict_batched(X_tr_t, y_enc, X_te_t, n_classes, batch_fn)) | |
| probabilities = np.mean(all_probs, axis=0) | |
| return le.inverse_transform(np.argmax(probabilities, axis=1)), probabilities | |