diff --git a/README.md b/README.md index c9165675..6b65d5e8 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,10 @@ For a detailed installation guide and advanced configuration → [Installatio > weightslab start example --clus # clustering > weightslab start example --gen # generation > ``` +> +> Browse every runnable example — including the **tabular** fraud-detection and +> ads-CTR usecases, each with a one-click **Colab** notebook — in the +> [examples gallery](weightslab/examples/README.md).
diff --git a/weightslab/data/data_utils.py b/weightslab/data/data_utils.py index db8e7f2d..3f5aac51 100644 --- a/weightslab/data/data_utils.py +++ b/weightslab/data/data_utils.py @@ -1,3 +1,4 @@ +import io import re import numpy as np import torch as th @@ -618,6 +619,20 @@ def load_raw_image_array(dataset, index, rank: int = 0) -> tuple: if hasattr(wrapped, '__getitem__'): np_img, is_volumetric, original_shape = _get_image_array_and_metadata(wrapped, index, rank=rank) + # Tabular samples (1-D feature vectors) have no spatial dims and cannot be + # PIL-encoded as an image. Render a small heatmap for display continuity; + # the caller transmits the actual values via build_tabular_raw_data_stat. + if getattr(np_img, "ndim", None) == 1: + from weightslab.trainer.services.data_image_utils import render_tabular_heatmap + thumb_bytes, _shp = render_tabular_heatmap(np_img) + thumb_pil = None + if thumb_bytes: + try: + thumb_pil = Image.open(io.BytesIO(thumb_bytes)).convert("RGB") + except Exception: + thumb_pil = None + return np_img, False, original_shape, thumb_pil + # Point-cloud samples (LiDAR etc.) cannot be PIL-encoded; render a # server-side 2D thumbnail (BEV, range image, or custom projection). # Guarded by both the task type and a shape heuristic so regular image diff --git a/weightslab/examples/Notebooks/PyTorch/ws-ads-recommendation.ipynb b/weightslab/examples/Notebooks/PyTorch/ws-ads-recommendation.ipynb new file mode 100644 index 00000000..c3700242 --- /dev/null +++ b/weightslab/examples/Notebooks/PyTorch/ws-ads-recommendation.ipynb @@ -0,0 +1,153 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": "\"Open" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "
\n\n \n \"WeightsLab\n\n \"License\"\n \"Stars\"\n \"Version\"\n
\n \"Open\n\n Welcome to the WeightsLab image-classification notebook! WeightsLab is an open-source PyTorch tool for dataset debugging, mislabel detection, and mid-training data curation. Browse the Docs for details, and raise an issue on GitHub for support.
" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "# Advertising CTR Recommendation with WeightsLab (tabular)\n\nThis notebook trains a **Wide & Deep** click-through-rate (CTR) model \u2014 the core of an advertising recommender \u2014 and instruments it with WeightsLab so every signal traces **back to the exact impression**.\n\nA sample is one ad impression (a **row**); the model input **is** the packed field vector (8 embedded categorical fields + 8 numeric features), which WeightsLab sends to the UI as a `vector`. Each field (`ad_category`, `placement`, `bid_price`, \u2026) is a **sortable column** in the List view.\n\n### What you'll do\n1. Install WeightsLab.\n2. Generate reproducible ad impressions at a calibrated ~20% CTR.\n3. Wrap the Wide & Deep model, optimizer, dataloaders, loss and metric.\n4. Train while streaming per-impression signals to Weights Studio.\n\n*Real drop-in datasets: Criteo, Avazu, MovieLens.*" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## Setup\n\nInstall WeightsLab from PyPI. These tabular demos are tiny \u2014 the free Colab CPU runtime is plenty (no GPU needed).\n\n> The **tabular input path** (the feature vector is sent to the UI as a `vector` \u2014 not a fake image) needs a WeightsLab build with tabular support (the `252-tabular-experiment` line or a release that includes it)." + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "\"PyPI\n\"PyPI\n\"PyPI" + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "%pip install torch torchvision" + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "%pip install --pre --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ \"weightslab==1.3.3.dev6\"" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## 1. Imports\n\n`weightslab` is imported as `wl`. The two `guard_*_context` managers scope a block as training vs. evaluation so signals are attributed to the right phase." + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "import tempfile, logging\nimport numpy as np\nimport torch\nimport torch.nn as nn\nimport torch.optim as optim\nfrom torch.utils.data import Dataset\nfrom torchmetrics.classification import Accuracy\nfrom tqdm.auto import tqdm\n\nimport weightslab as wl\nfrom weightslab.components.global_monitoring import (\n guard_training_context,\n guard_testing_context,\n)\n\nlogging.basicConfig(level=logging.ERROR)\ndevice = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\nprint(f\"Using device: {device}\")" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## 2. A synthetic CTR dataset (rows, not images)\n\n`make_synthetic_ctr` builds impressions with 8 categorical fields + 8 numeric features and a binary `clicked` label at ~20% CTR, driven by per-field effects plus an `ad_category x user_segment` interaction. The model input packs the field values into one vector; `get_items(...)` exposes readable field values as metadata columns. The **Wide & Deep** model embeds each categorical field and MLPs over the embeddings + numeric features." + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "CATEGORICAL_FIELDS = [\"user_segment\", \"ad_category\", \"device_type\", \"os\",\n \"publisher\", \"placement\", \"region\", \"hour_bucket\"]\nCARD = [6, 10, 4, 4, 12, 5, 8, 6]\nNUMERIC_FIELDS = [\"ad_position\", \"bid_price\", \"user_age\", \"session_depth\",\n \"historical_ctr\", \"days_since_last_click\", \"num_ads_seen_today\",\n \"creative_freshness\"]\nNUM_CAT, NUM_NUM = len(CATEGORICAL_FIELDS), len(NUMERIC_FIELDS)\nVOCABS = {\"device_type\": [\"mobile\", \"desktop\", \"tablet\", \"ctv\"],\n \"os\": [\"android\", \"ios\", \"windows\", \"other\"],\n \"placement\": [\"banner\", \"sidebar\", \"interstitial\", \"native\", \"video\"],\n \"hour_bucket\": [\"night\", \"early\", \"morning\", \"midday\", \"afternoon\", \"evening\"]}\nTARGET_CTR = 0.20\n\n\ndef _label(field, code):\n v = VOCABS.get(field)\n return v[code] if v and 0 <= code < len(v) else f\"{field}_{code}\"\n\n\ndef make_synthetic_ctr(n, seed=0):\n \"\"\"Return (cat[int], num[float], y): shared ground-truth CTR model (seed 12345).\"\"\"\n p = np.random.default_rng(12345)\n cat_w = [p.normal(0, 0.8, c) for c in CARD]\n num_w = p.normal(0, 0.5, NUM_NUM)\n inter = p.normal(0, 0.7, (CARD[1], CARD[0]))\n rng = np.random.default_rng(seed)\n cat = np.stack([rng.integers(0, c, n) for c in CARD], axis=1).astype(np.int64)\n num = rng.normal(0, 1, (n, NUM_NUM)).astype(np.float32)\n logit = sum(w[cat[:, f]] for f, w in enumerate(cat_w)) + num @ num_w\n logit = logit + inter[cat[:, 1], cat[:, 0]]\n logit -= logit.mean()\n lo, hi = -20.0, 20.0 # calibrate bias to hit ~20% mean CTR\n for _ in range(60):\n mid = 0.5 * (lo + hi)\n if (1 / (1 + np.exp(-(logit + mid)))).mean() < TARGET_CTR:\n lo = mid\n else:\n hi = mid\n logit += 0.5 * (lo + hi)\n y = (rng.uniform(0, 1, n) < 1 / (1 + np.exp(-logit))).astype(np.int64)\n return cat, num, y\n\n\nclass AdsCTRDataset(Dataset):\n \"\"\"Yields (packed_vector, id, label); get_items() adds field columns.\"\"\"\n\n def __init__(self, n, seed=0):\n cat, num, y = make_synthetic_ctr(n, seed)\n self.cat, self.num = cat, num\n self.features = torch.from_numpy(np.concatenate([cat.astype(np.float32), num], 1))\n self.labels = torch.from_numpy(y)\n\n def __len__(self):\n return len(self.features)\n\n def __getitem__(self, idx):\n return self.features[idx], idx, int(self.labels[idx])\n\n def get_items(self, idx, include_metadata=False, include_labels=False, include_images=False):\n x = self.features[idx] if include_images else None\n target = int(self.labels[idx]) if include_labels else None\n meta = None\n if include_metadata:\n meta = {n: _label(n, int(self.cat[idx][f])) for f, n in enumerate(CATEGORICAL_FIELDS)}\n meta.update({n: round(float(self.num[idx][j]), 4) for j, n in enumerate(NUMERIC_FIELDS)})\n return x, idx, target, meta\n\n\nclass WideDeepCTR(nn.Module):\n def __init__(self, card=CARD, num_numeric=NUM_NUM, emb=8, hidden=128, num_classes=2):\n super().__init__()\n self.card = list(card)\n self.emb = nn.ModuleList([nn.Embedding(c, emb) for c in self.card])\n self.deep = nn.Sequential(\n nn.Linear(emb * len(self.card) + num_numeric, hidden), nn.ReLU(), nn.Dropout(0.1),\n nn.Linear(hidden, hidden // 2), nn.ReLU(), nn.Linear(hidden // 2, num_classes))\n self.wide_cat = nn.ModuleList([nn.Embedding(c, num_classes) for c in self.card])\n self.wide_num = nn.Linear(num_numeric, num_classes)\n\n def forward(self, x):\n flat = x.reshape(x.shape[0], -1)\n cidx = flat[:, :len(self.card)].round().long()\n cidx = cidx.clamp(min=torch.zeros(len(self.card), device=x.device, dtype=torch.long),\n max=torch.tensor(self.card, device=x.device) - 1)\n numeric = flat[:, len(self.card):]\n deep = self.deep(torch.cat([e(cidx[:, i]) for i, e in enumerate(self.emb)] + [numeric], 1))\n wide = self.wide_num(numeric)\n for i, e in enumerate(self.wide_cat):\n wide = wide + e(cidx[:, i])\n return deep + wide\n\n\ndef build_dataset(n, seed=0):\n return AdsCTRDataset(n, seed)\n\n\ndef build_model():\n return WideDeepCTR()\n" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## 3. Configuration\n\nEvery tunable lives here in one dict, like a `config.yaml` with comments. Wrapping it with `flag=\"hyperparameters\"` lets Weights Studio read (and live-edit) these values while training." + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "config = {\n \"experiment_name\": \"ads_ctr_wide_deep\",\n \"device\": str(device),\n \"root_log_dir\": tempfile.mkdtemp(prefix=\"weightslab_ads_\"),\n \"learning_rate\": 0.005,\n \"training_steps_to_do\": 3000,\n \"eval_full_to_train_steps_ratio\": 150,\n \"write_export_ratio\": 500,\n \"class_weights\": [1.0, 4.0], # [no-click, click] \u2014 up-weight clicks\n \"dataset\": {\"seed\": 0, \"n_train\": 8000, \"n_test\": 2000},\n \"data\": {\"train_loader\": {\"batch_size\": 64},\n \"test_loader\": {\"batch_size\": 256}},\n}\nwl.watch_or_edit(config, flag=\"hyperparameters\", poll_interval=1.0)" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## 4. Wrap the training objects\n\nThis is the heart of WeightsLab. Each object passes through `wl.watch_or_edit(...)` with a `flag` describing its role. The tracked dataset's `get_items()` exposes every feature as a **sortable column**; `preload_metadata=True` loads them at init." + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "cfg = config\ntrain_ds = build_dataset(cfg['dataset']['n_train'], seed=cfg['dataset']['seed'])\ntest_ds = build_dataset(cfg['dataset']['n_test'], seed=cfg['dataset']['seed'] + 1)\n\nmodel = wl.watch_or_edit(build_model().to(device), flag='model', device=device)\noptimizer = wl.watch_or_edit(\n optim.Adam(model.parameters(), lr=cfg['learning_rate']), flag='optimizer')\n\ntrain_loader = wl.watch_or_edit(\n train_ds, flag='data', loader_name='train_loader',\n batch_size=cfg['data']['train_loader']['batch_size'], shuffle=True,\n is_training=True, preload_labels=True, preload_metadata=True)\ntest_loader = wl.watch_or_edit(\n test_ds, flag='data', loader_name='test_loader',\n batch_size=cfg['data']['test_loader']['batch_size'], shuffle=False,\n is_training=False, preload_labels=True, preload_metadata=True)\n\n# Class weights counter the minority (positive) class prevalence.\ncw = torch.tensor(cfg['class_weights'], dtype=torch.float32, device=device)\ntrain_criterion = wl.watch_or_edit(\n nn.CrossEntropyLoss(weight=cw, reduction='none'),\n flag='loss', signal_name='train-loss-CE', log=True)\ntest_criterion = wl.watch_or_edit(\n nn.CrossEntropyLoss(weight=cw, reduction='none'),\n flag='loss', signal_name='test-loss-CE', log=True)\nmetric = wl.watch_or_edit(\n Accuracy(task='multiclass', num_classes=2).to(device),\n flag='metric', signal_name='metric-ACC', log=True)" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## 5. Train and evaluate steps\n\nThe `guard_training_context` / `guard_testing_context` blocks tell WeightsLab the phase. `criterion(..., batch_ids=ids, preds=preds)` passes the sample ids so loss is stored **per sample**, and `wl.save_signals(...)` logs a custom per-sample accuracy signal during evaluation." + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "def train(loader, model, optimizer, criterion, device):\n with guard_training_context:\n inputs, ids, labels = next(loader)\n inputs, labels = inputs.to(device), labels.to(device)\n optimizer.zero_grad()\n logits = model(inputs)\n preds = logits.argmax(dim=1, keepdim=True)\n loss = criterion(logits.float(), labels.long(), batch_ids=ids, preds=preds)\n total = loss.mean()\n total.backward()\n optimizer.step()\n return total.detach().cpu().item()\n\n\ndef test(loader, model, criterion, metric, device, n_batches):\n losses = torch.tensor(0.0, device=device)\n for inputs, ids, labels in loader:\n with guard_testing_context:\n inputs, labels = inputs.to(device), labels.to(device)\n logits = model(inputs)\n preds = logits.argmax(dim=1, keepdim=True)\n losses += criterion(logits, labels, batch_ids=ids, preds=preds).mean()\n metric.update(logits, labels)\n correct = (preds.view(-1) == labels.view(-1)).float()\n wl.save_signals(preds_raw=logits, targets=labels, batch_ids=ids,\n signals={\"test_metric/Accuracy_per_sample\": correct}, preds=preds)\n return (losses / max(1, n_batches)).item(), (metric.compute() * 100).item()\n" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## 6. Serve and train\n\n`wl.serve(serving_grpc=True, serving_bore=True)` starts the background gRPC server (non-blocking) and a `bore.pub` tunnel so Weights Studio on your own machine can reach this Colab backend. `wl.start_training(...)` flips the experiment into the *training* state, then we run the loop." + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "wl.serve(serving_grpc=True, serving_bore=True)" + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "wl.start_training(timeout=3)\n\nsteps = config['training_steps_to_do']\neval_ratio = config['eval_full_to_train_steps_ratio']\nn_test_batches = len(test_loader)\n\ntest_loss = test_acc = None\npbar = tqdm(range(steps), desc='Training')\nfor step in pbar:\n age = model.get_age() if hasattr(model, 'get_age') else step\n train_loss = train(train_loader, model, optimizer, train_criterion, device)\n if age > 0 and age % eval_ratio == 0:\n test_loss, test_acc = test(test_loader, model, test_criterion, metric, device, n_test_batches)\n postfix = {'loss': f'{train_loss:.3f}'}\n if test_acc is not None:\n postfix['test_acc'] = f'{test_acc:.1f}%'\n pbar.set_postfix(postfix)\n\nwl.write_history()\nwl.write_dataframe()\nprint('Training complete.')" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## See it live in Weights Studio\n\nEverything above runs headless. The payoff is **Weights Studio**, where each row is one record and every feature is a **sortable column**.\n\nStudio runs as a local Docker stack, and **Colab has no Docker daemon**, so you run Studio on your own machine and point it at this notebook's backend via the `bore.pub:` endpoint **printed in Section 6**.\n\n**On your machine** (with Docker Desktop):\n```bash\npip install weightslab\nweightslab ui launch # opens http://localhost:5173\nweightslab tunnel bore.pub:12345 # the host:port printed in Section 6\n```\n\nThen open **http://localhost:5173** and switch the Data Exploration board to the **List** view." + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## Curate in the UI\n\nIn Weights Studio, switch the Data Exploration board to the **List** view:\n\n1. **Sort by `test-loss-CE` descending** and generate its histogram to find the impressions the model ranks worst.\n2. **Lock** the loss sort, then add `ad_category` or `placement` as a secondary sort to see which segments the model struggles on.\n3. **Right-click a column** to clone, reset the sort, or generate a histogram.\n4. **Discard** noisy impressions and keep training to sharpen the ranker." + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "---\n\n
\nCrafted by GrayboxTech - if WeightsLab helps you catch a bad label, drop us a star on GitHub.\n
" + } + ], + "metadata": { + "colab": { + "name": "ws-ads-recommendation.ipynb", + "provenance": [], + "include_colab_link": true + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/weightslab/examples/Notebooks/PyTorch/ws-fraud-detection.ipynb b/weightslab/examples/Notebooks/PyTorch/ws-fraud-detection.ipynb new file mode 100644 index 00000000..4ef0a3d9 --- /dev/null +++ b/weightslab/examples/Notebooks/PyTorch/ws-fraud-detection.ipynb @@ -0,0 +1,153 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": "\"Open" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "
\n\n \n \"WeightsLab\n\n \"License\"\n \"Stars\"\n \"Version\"\n
\n \"Open\n\n Welcome to the WeightsLab image-classification notebook! WeightsLab is an open-source PyTorch tool for dataset debugging, mislabel detection, and mid-training data curation. Browse the Docs for details, and raise an issue on GitHub for support.
" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "# Bank Fraud Detection with WeightsLab (tabular)\n\nThis notebook trains a small MLP to flag **fraudulent bank card transactions** and instruments it with WeightsLab so every training signal is traced **back to the exact transaction** producing it.\n\nThere are no images: a sample is one transaction (a **row**), the model input **is** the 16-feature vector, and WeightsLab sends that vector to the UI as a `vector` (not a fake image). Each raw feature (`amount`, `merchant_risk`, `geo_distance_km`, \u2026) is a **sortable column** in the List Exploration view.\n\n### What you'll do\n1. Install WeightsLab.\n2. Generate a reproducible synthetic fraud stream (no download).\n3. Wrap the model, optimizer, dataloaders, loss and metric.\n4. Train while streaming per-transaction loss/prediction to Weights Studio.\n\n*Real drop-in datasets: Kaggle Credit Card Fraud (ULB), PaySim.*" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## Setup\n\nInstall WeightsLab from PyPI. These tabular demos are tiny \u2014 the free Colab CPU runtime is plenty (no GPU needed).\n\n> The **tabular input path** (the feature vector is sent to the UI as a `vector` \u2014 not a fake image) needs a WeightsLab build with tabular support (the `252-tabular-experiment` line or a release that includes it)." + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "\"PyPI\n\"PyPI\n\"PyPI" + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "%pip install torch torchvision" + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "%pip install --pre --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ \"weightslab==1.3.3.dev6\"" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## 1. Imports\n\n`weightslab` is imported as `wl`. The two `guard_*_context` managers scope a block as training vs. evaluation so signals are attributed to the right phase." + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "import tempfile, logging\nimport numpy as np\nimport torch\nimport torch.nn as nn\nimport torch.optim as optim\nfrom torch.utils.data import Dataset\nfrom torchmetrics.classification import Accuracy\nfrom tqdm.auto import tqdm\n\nimport weightslab as wl\nfrom weightslab.components.global_monitoring import (\n guard_training_context,\n guard_testing_context,\n)\n\nlogging.basicConfig(level=logging.ERROR)\ndevice = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\nprint(f\"Using device: {device}\")" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## 2. A synthetic fraud dataset (rows, not images)\n\n`make_synthetic_fraud` builds reproducible transactions: 16 numeric features with a binary label (~12% fraud). Fraud rows come from shifted distributions (larger amounts, off-hours activity, higher merchant risk, device changes, larger geo jumps). The dataset returns the **standardized feature vector** as the model input, and `get_items(...)` exposes the **raw** values as metadata columns." + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "FEATURE_NAMES = [\n \"amount\", \"old_balance\", \"new_balance\", \"balance_delta\",\n \"txn_hour\", \"txn_day_of_week\", \"txn_count_1h\", \"txn_count_24h\",\n \"avg_amount_7d\", \"std_amount_7d\", \"merchant_risk\", \"device_change\",\n \"geo_distance_km\", \"is_foreign\", \"account_age_days\", \"num_prior_disputes\",\n]\nNUM_FEATURES = len(FEATURE_NAMES) # 16\nFRAUD_RATE = 0.12\n\n\ndef make_synthetic_fraud(n_samples, seed=0):\n \"\"\"Return (X_std, X_raw, y): standardized input, raw display values, label.\"\"\"\n rng = np.random.default_rng(seed)\n n_fraud = max(1, round(n_samples * FRAUD_RATE))\n n_legit = max(1, n_samples - n_fraud)\n\n def draw(n, fraud):\n amount = rng.gamma(2.0, 180.0 if fraud else 60.0, n)\n old_balance = rng.gamma(2.0, 800.0, n)\n spent = amount * (rng.uniform(0.6, 1.4, n) if fraud else rng.uniform(0.0, 0.6, n))\n new_balance = np.clip(old_balance - spent, 0, None)\n balance_delta = old_balance - new_balance\n txn_hour = (rng.normal(2.5, 2.0, n) % 24) if fraud else rng.normal(13.0, 4.0, n)\n txn_dow = rng.integers(0, 7, n).astype(float)\n txn_count_1h = rng.poisson(4.0 if fraud else 1.0, n).astype(float)\n txn_count_24h = rng.poisson(18.0 if fraud else 6.0, n).astype(float)\n avg_amount_7d = rng.gamma(2.0, 90.0 if fraud else 70.0, n)\n std_amount_7d = rng.gamma(2.0, 60.0 if fraud else 25.0, n)\n merchant_risk = rng.beta(5.0, 2.0, n) if fraud else rng.beta(2.0, 6.0, n)\n device_change = rng.binomial(1, 0.55 if fraud else 0.08, n).astype(float)\n geo = rng.gamma(2.0, 400.0 if fraud else 25.0, n)\n is_foreign = rng.binomial(1, 0.45 if fraud else 0.05, n).astype(float)\n account_age = rng.gamma(2.0, 120.0 if fraud else 500.0, n)\n disputes = rng.poisson(1.5 if fraud else 0.2, n).astype(float)\n return np.stack([amount, old_balance, new_balance, balance_delta, txn_hour,\n txn_dow, txn_count_1h, txn_count_24h, avg_amount_7d,\n std_amount_7d, merchant_risk, device_change, geo,\n is_foreign, account_age, disputes], axis=1)\n\n x_raw = np.concatenate([draw(n_legit, False), draw(n_fraud, True)]).astype(np.float64)\n y = np.concatenate([np.zeros(n_legit, np.int64), np.ones(n_fraud, np.int64)])\n mean, std = x_raw.mean(0, keepdims=True), x_raw.std(0, keepdims=True)\n std[std == 0] = 1.0\n x_std = (x_raw - mean) / std\n perm = rng.permutation(len(x_raw))\n return x_std[perm].astype(np.float32), x_raw[perm].astype(np.float32), y[perm]\n\n\nclass FraudDataset(Dataset):\n \"\"\"Yields (feature_vector, id, label); get_items() adds feature columns.\"\"\"\n\n def __init__(self, n_samples, seed=0):\n xs, xr, y = make_synthetic_fraud(n_samples, seed)\n self.features = torch.from_numpy(xs) # model input\n self.raw = xr # display values\n self.labels = torch.from_numpy(y)\n\n def __len__(self):\n return len(self.features)\n\n def __getitem__(self, idx):\n return self.features[idx], idx, int(self.labels[idx])\n\n def get_items(self, idx, include_metadata=False, include_labels=False, include_images=False):\n x = self.features[idx] if include_images else None\n target = int(self.labels[idx]) if include_labels else None\n meta = ({n: round(float(self.raw[idx][i]), 4) for i, n in enumerate(FEATURE_NAMES)}\n if include_metadata else None)\n return x, idx, target, meta\n\n\nclass FraudMLP(nn.Module):\n def __init__(self, in_features=NUM_FEATURES, hidden=64, num_classes=2):\n super().__init__()\n self.net = nn.Sequential(\n nn.Flatten(), nn.Linear(in_features, hidden), nn.ReLU(), nn.Dropout(0.1),\n nn.Linear(hidden, hidden // 2), nn.ReLU(), nn.Linear(hidden // 2, num_classes))\n\n def forward(self, x):\n return self.net(x)\n\n\ndef build_dataset(n, seed=0):\n return FraudDataset(n, seed)\n\n\ndef build_model():\n return FraudMLP()\n" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## 3. Configuration\n\nEvery tunable lives here in one dict, like a `config.yaml` with comments. Wrapping it with `flag=\"hyperparameters\"` lets Weights Studio read (and live-edit) these values while training." + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "config = {\n \"experiment_name\": \"fraud_detection_mlp\",\n \"device\": str(device),\n \"root_log_dir\": tempfile.mkdtemp(prefix=\"weightslab_fraud_\"),\n \"learning_rate\": 0.005,\n \"training_steps_to_do\": 2000,\n \"eval_full_to_train_steps_ratio\": 100,\n \"write_export_ratio\": 500,\n \"class_weights\": [1.0, 4.0], # [legit, fraud] \u2014 up-weight fraud\n \"dataset\": {\"seed\": 0, \"n_train\": 4000, \"n_test\": 1000},\n \"data\": {\"train_loader\": {\"batch_size\": 32},\n \"test_loader\": {\"batch_size\": 128}},\n}\nwl.watch_or_edit(config, flag=\"hyperparameters\", poll_interval=1.0)" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## 4. Wrap the training objects\n\nThis is the heart of WeightsLab. Each object passes through `wl.watch_or_edit(...)` with a `flag` describing its role. The tracked dataset's `get_items()` exposes every feature as a **sortable column**; `preload_metadata=True` loads them at init." + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "cfg = config\ntrain_ds = build_dataset(cfg['dataset']['n_train'], seed=cfg['dataset']['seed'])\ntest_ds = build_dataset(cfg['dataset']['n_test'], seed=cfg['dataset']['seed'] + 1)\n\nmodel = wl.watch_or_edit(build_model().to(device), flag='model', device=device)\noptimizer = wl.watch_or_edit(\n optim.Adam(model.parameters(), lr=cfg['learning_rate']), flag='optimizer')\n\ntrain_loader = wl.watch_or_edit(\n train_ds, flag='data', loader_name='train_loader',\n batch_size=cfg['data']['train_loader']['batch_size'], shuffle=True,\n is_training=True, preload_labels=True, preload_metadata=True)\ntest_loader = wl.watch_or_edit(\n test_ds, flag='data', loader_name='test_loader',\n batch_size=cfg['data']['test_loader']['batch_size'], shuffle=False,\n is_training=False, preload_labels=True, preload_metadata=True)\n\n# Class weights counter the minority (positive) class prevalence.\ncw = torch.tensor(cfg['class_weights'], dtype=torch.float32, device=device)\ntrain_criterion = wl.watch_or_edit(\n nn.CrossEntropyLoss(weight=cw, reduction='none'),\n flag='loss', signal_name='train-loss-CE', log=True)\ntest_criterion = wl.watch_or_edit(\n nn.CrossEntropyLoss(weight=cw, reduction='none'),\n flag='loss', signal_name='test-loss-CE', log=True)\nmetric = wl.watch_or_edit(\n Accuracy(task='multiclass', num_classes=2).to(device),\n flag='metric', signal_name='metric-ACC', log=True)" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## 5. Train and evaluate steps\n\nThe `guard_training_context` / `guard_testing_context` blocks tell WeightsLab the phase. `criterion(..., batch_ids=ids, preds=preds)` passes the sample ids so loss is stored **per sample**, and `wl.save_signals(...)` logs a custom per-sample accuracy signal during evaluation." + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "def train(loader, model, optimizer, criterion, device):\n with guard_training_context:\n inputs, ids, labels = next(loader)\n inputs, labels = inputs.to(device), labels.to(device)\n optimizer.zero_grad()\n logits = model(inputs)\n preds = logits.argmax(dim=1, keepdim=True)\n loss = criterion(logits.float(), labels.long(), batch_ids=ids, preds=preds)\n total = loss.mean()\n total.backward()\n optimizer.step()\n return total.detach().cpu().item()\n\n\ndef test(loader, model, criterion, metric, device, n_batches):\n losses = torch.tensor(0.0, device=device)\n for inputs, ids, labels in loader:\n with guard_testing_context:\n inputs, labels = inputs.to(device), labels.to(device)\n logits = model(inputs)\n preds = logits.argmax(dim=1, keepdim=True)\n losses += criterion(logits, labels, batch_ids=ids, preds=preds).mean()\n metric.update(logits, labels)\n correct = (preds.view(-1) == labels.view(-1)).float()\n wl.save_signals(preds_raw=logits, targets=labels, batch_ids=ids,\n signals={\"test_metric/Accuracy_per_sample\": correct}, preds=preds)\n return (losses / max(1, n_batches)).item(), (metric.compute() * 100).item()\n" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## 6. Serve and train\n\n`wl.serve(serving_grpc=True, serving_bore=True)` starts the background gRPC server (non-blocking) and a `bore.pub` tunnel so Weights Studio on your own machine can reach this Colab backend. `wl.start_training(...)` flips the experiment into the *training* state, then we run the loop." + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "wl.serve(serving_grpc=True, serving_bore=True)" + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "wl.start_training(timeout=3)\n\nsteps = config['training_steps_to_do']\neval_ratio = config['eval_full_to_train_steps_ratio']\nn_test_batches = len(test_loader)\n\ntest_loss = test_acc = None\npbar = tqdm(range(steps), desc='Training')\nfor step in pbar:\n age = model.get_age() if hasattr(model, 'get_age') else step\n train_loss = train(train_loader, model, optimizer, train_criterion, device)\n if age > 0 and age % eval_ratio == 0:\n test_loss, test_acc = test(test_loader, model, test_criterion, metric, device, n_test_batches)\n postfix = {'loss': f'{train_loss:.3f}'}\n if test_acc is not None:\n postfix['test_acc'] = f'{test_acc:.1f}%'\n pbar.set_postfix(postfix)\n\nwl.write_history()\nwl.write_dataframe()\nprint('Training complete.')" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## See it live in Weights Studio\n\nEverything above runs headless. The payoff is **Weights Studio**, where each row is one record and every feature is a **sortable column**.\n\nStudio runs as a local Docker stack, and **Colab has no Docker daemon**, so you run Studio on your own machine and point it at this notebook's backend via the `bore.pub:` endpoint **printed in Section 6**.\n\n**On your machine** (with Docker Desktop):\n```bash\npip install weightslab\nweightslab ui launch # opens http://localhost:5173\nweightslab tunnel bore.pub:12345 # the host:port printed in Section 6\n```\n\nThen open **http://localhost:5173** and switch the Data Exploration board to the **List** view." + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## Curate in the UI\n\nIn Weights Studio, switch the Data Exploration board to the **List** view:\n\n1. **Sort by `train-loss-CE` descending** and generate its histogram to find the hardest transactions \u2014 usually the frauds the model still misses.\n2. **Lock** the loss sort, then add `merchant_risk` or `amount` as a secondary sort to see which feature ranges drive the errors.\n3. **Right-click a column** to clone it, reset the sort, or generate a histogram \u2014 the same actions you use on image datasets.\n4. **Discard** mislabeled or leaked rows and keep training \u2014 no restart." + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "---\n\n
\nCrafted by GrayboxTech - if WeightsLab helps you catch a bad label, drop us a star on GitHub.\n
" + } + ], + "metadata": { + "colab": { + "name": "ws-fraud-detection.ipynb", + "provenance": [], + "include_colab_link": true + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/weightslab/examples/PyTorch/wl-ads-recommendation/README.md b/weightslab/examples/PyTorch/wl-ads-recommendation/README.md new file mode 100644 index 00000000..334b753d --- /dev/null +++ b/weightslab/examples/PyTorch/wl-ads-recommendation/README.md @@ -0,0 +1,102 @@ +# WeightsLab — Advertising CTR Recommendation (tabular, pure PyTorch) + +[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/GrayboxTech/weightslab/blob/252-tabular-experiment/weightslab/examples/Notebooks/PyTorch/ws-ads-recommendation.ipynb) + +A fully-runnable **click-through-rate (CTR) prediction** example — the core of +an advertising recommendation system. A **Wide & Deep** model learns to predict +`P(click)` for `(user, ad, context)` impressions, streaming per-impression loss / +prediction / accuracy to the WeightsLab UI. Being tabular, it's a natural fit +for the **List Exploration (tabular) view** — sort by `loss` or `prediction` to +inspect the impressions the model ranks best and worst. + +Everything is plain PyTorch + NumPy — no external download. The impressions are +generated in-process so runs are reproducible and offline. + +## Quick start + +```bash +cd weightslab/examples/PyTorch/wl-ads-recommendation +pip install -r requirements.txt +python main.py +``` + +Then open the UI (e.g. `http://localhost:5173`), switch the Data Exploration +board to **List** view, press play, and sort columns to explore. + +## The data & model + +`make_synthetic_ctr(n, seed)` (in `utils/data.py`) generates reproducible ad +impressions with: + +* **8 categorical fields** — `user_segment`, `ad_category`, `device_type`, `os`, + `publisher`, `placement`, `region`, `hour_bucket` (embedded by the model). +* **8 numeric features** — `ad_position`, `bid_price`, `user_age`, + `session_depth`, `historical_ctr`, … (standardized). +* a binary `clicked` label at a calibrated **~20% CTR**, driven by per-field + effects plus an `ad_category × user_segment` interaction. + +The 16 field values are packed into a `1x4x4` tensor per impression (heatmap +thumbnail in the grid); the model unpacks them back into indices + numerics. + +**Model — Wide & Deep** (`utils/model.py`, after Cheng et al., 2016): +* *Deep*: per-field embeddings ⊕ numeric → MLP (generalizes feature interactions). +* *Wide*: first-order per-category effects + linear numeric term (memorizes + strong direct signals). +The two logit heads are summed. Related architectures: DeepFM, Factorization +Machines. + +### Using a real dataset + +Swap `AdsCTRDataset` for a loader over a real CTR log and set +`CATEGORICAL_CARDINALITIES` to your vocab sizes: + +* **Criteo Display Advertising** — 13 numeric + 26 categorical fields. + +* **Avazu CTR** — +* **MovieLens** (recommender variant) — + +Keep the `__getitem__` contract `(packed_features_as_image, idx, label)`. + +## What "a sample" is here + +There are no images — **each sample is one ad impression (a row)**, and the +model input **is** the packed 1-D field vector (not a reshaped image). +WeightsLab carries that vector through gRPC as a `raw_data` stat of type +`vector` (the actual values), so `inputs`, `labels`/`target` and `metadata` all +reach the UI. The 8 categorical fields (as readable labels) and 8 numeric +features are also exposed as **sortable columns** via the dataset's +`get_items()` metadata contract (`preload_metadata=True`), so the List +Exploration view shows real tabular columns (`ad_category`, `placement`, +`bid_price`, …) alongside the tracked stats below. Sort, lock, histograms, +discard/restore and neuron ops all work as on MNIST — they operate on the +per-sample ledger, not on pixels. + +## What you'll see in the UI + +| Signal / column | Meaning | +| ---------------------------------------- | --------------------------------------------- | +| field columns (`ad_category`, `placement`, `bid_price`, …) | The 16 impression fields, sortable | +| `train-loss-CE`, `test-loss-CE` | Weighted cross-entropy per split | +| `metric-ACC` | Overall accuracy | +| `test_metric/PredictedCTR_per_sample` | Model's predicted `P(click)` per impression | +| `test_metric/Accuracy_per_sample` | Per-impression correctness (0/1) | +| `target`, `prediction` columns | Per-sample truth/pred to sort/lock in List view | + +## Test it + +```bash +# Fast, offline unit tests (pure PyTorch, no gRPC server): +python -m pytest test_ads_recommendation.py -v + +# End-to-end integration check (needs weightslab installed): drives the tracked +# loaders + watched loss + gRPC server, then asserts the ledger dataframe the UI +# reads has per-sample rows, every field as a column, target/prediction/loss, +# and a live gRPC endpoint. +python verify_integration.py +``` + +The unit tests cover schema, reproducibility, calibrated CTR, pack/unpack +roundtrip, `get_items` metadata columns, the model forward pass, and that +training reduces loss and **ranks real clicks above non-clicks** on a held-out +split (rank-AUC > 0.6). `verify_integration.py` proves WeightsLab is fully wired +in tabular mode. diff --git a/weightslab/examples/PyTorch/wl-ads-recommendation/config.yaml b/weightslab/examples/PyTorch/wl-ads-recommendation/config.yaml new file mode 100644 index 00000000..856e6f1c --- /dev/null +++ b/weightslab/examples/PyTorch/wl-ads-recommendation/config.yaml @@ -0,0 +1,52 @@ +# Global configuration +experiment_name: ads_ctr_wide_deep +device: auto +training_steps_to_do: null # null = train until manually stopped from the UI +# root_log_dir: ... + +compute_natural_sort: false + +# Experiment parameters +eval_full_to_train_steps_ratio: 100 +experiment_dump_to_train_steps_ratio: 250 +write_export_ratio: 100 +skip_checkpoint_load: false +tqdm_display: true +is_training: false # Start paused; press play in the UI. + +# Global dataframe storage +ledger_enable_flushing_threads: true +ledger_enable_h5_persistence: true +ledger_flush_max_rows: 15000 +ledger_flush_interval: 30.0 + +# Clients +serving_grpc: true + +# Model (Wide & Deep) +model: + emb_dim: 8 + hidden: 128 + +# Optimizer +optimizer: + lr: 0.005 + +# Class weighting to counter ~20% click prevalence [no-click, click]. +class_weights: [1.0, 4.0] + +# Synthetic dataset generation (reproducible, no download). +dataset: + seed: 0 + n_train: 8000 + n_test: 2000 + +# DataLoader parameters +data: + train_loader: + shuffle: true + batch_size: 64 + test_loader: + shuffle: false + batch_size: 256 + drop_last: false diff --git a/weightslab/examples/PyTorch/wl-ads-recommendation/main.py b/weightslab/examples/PyTorch/wl-ads-recommendation/main.py new file mode 100644 index 00000000..852defbf --- /dev/null +++ b/weightslab/examples/PyTorch/wl-ads-recommendation/main.py @@ -0,0 +1,284 @@ +"""WeightsLab example: advertising CTR recommendation (tabular, PyTorch). + +A Wide & Deep model predicts click-through-rate over synthetic ad impressions +(8 categorical fields + 8 numeric features), wired into WeightsLab so the run +streams per-impression stats (loss, prediction, target) to the UI. Being +tabular, it pairs naturally with the List Exploration (tabular) view — sort by +loss or prediction to inspect the impressions the model ranks best/worst. + +Run: + cd weightslab/examples/PyTorch/wl-ads-recommendation + python main.py + +The dataset/model live in ``utils/`` (pure PyTorch) so they can be unit tested +without the gRPC backend — see ``test_ads_recommendation.py``. +""" + +import itertools +import os +import time +import logging +import tempfile + +import yaml +import tqdm +import torch +import torch.nn as nn +import torch.optim as optim + +from torchmetrics.classification import Accuracy + +import weightslab as wl +from weightslab.components.global_monitoring import ( + guard_training_context, + guard_testing_context, +) + +from utils.data import AdsCTRDataset, CATEGORICAL_CARDINALITIES, NUM_NUMERIC +from utils.model import WideDeepCTR + + +logging.basicConfig(level=logging.ERROR) +logger = logging.getLogger(__name__) + + +# ----------------------------------------------------------------------------- +# Train / Test steps +# ----------------------------------------------------------------------------- +def train(loader, model, optimizer, criterion_mlt, device): + """Single training step using the tracked dataloader + watched loss.""" + with guard_training_context: + (inputs, ids, labels) = next(loader) + inputs = inputs.to(device) + labels = labels.to(device) + + optimizer.zero_grad() + preds_raw = model(inputs) + preds = preds_raw.argmax(dim=1, keepdim=True) + + loss_batch_mlt = criterion_mlt( + preds_raw.float(), + labels.long(), + batch_ids=ids, + preds=preds, + ) + total_loss = loss_batch_mlt.mean() + + total_loss.backward() + optimizer.step() + + return total_loss.detach().cpu().item() + + +def test(loader, model, criterion_mlt, metric_mlt, device, test_loader_len): + """Full evaluation pass over the test loader, logging per-sample signals.""" + losses = torch.tensor(0.0, device=device) + + for (inputs, ids, labels) in loader: + with guard_testing_context: + inputs = inputs.to(device) + labels = labels.to(device) + + outputs = model(inputs) + preds = outputs.argmax(dim=1, keepdim=True) + + loss_batch = criterion_mlt(outputs, labels, batch_ids=ids, preds=preds) + losses += torch.mean(loss_batch) + metric_mlt.update(outputs, labels) + + preds_flat = preds.view(-1) + labels_flat = labels.view(-1) + prob_click = torch.softmax(outputs, dim=1)[:, 1] + correct_per_sample = (preds_flat == labels_flat).float() + + signals = { + "test_metric/Accuracy_per_sample": correct_per_sample, + "test_metric/PredictedCTR_per_sample": prob_click, + } + wl.save_signals( + preds_raw=outputs, + targets=labels, + batch_ids=ids, + signals=signals, + preds=preds, + ) + + loss = losses / max(1, test_loader_len) + metric = metric_mlt.compute() * 100 + + return loss.detach().cpu().item(), metric.detach().cpu().item() + + +# ----------------------------------------------------------------------------- +# Main +# ----------------------------------------------------------------------------- +if __name__ == "__main__": + start_time = time.time() + + parameters = {} + config_path = os.path.join(os.path.dirname(__file__), "config.yaml") + if os.path.exists(config_path): + with open(config_path, "r") as fh: + parameters = yaml.safe_load(fh) or {} + parameters = parameters or {} + + # ---- sensible defaults / normalization ---- + parameters.setdefault("experiment_name", "ads_ctr_wide_deep") + parameters.setdefault("device", "auto") + parameters.setdefault("training_steps_to_do", 1000000) + parameters.setdefault("eval_full_to_train_steps_ratio", 100) + + exp_name = parameters["experiment_name"] + + wl.watch_or_edit(parameters, flag="hyperparameters", poll_interval=1.0) + + if parameters.get("device", "auto") == "auto": + parameters["device"] = torch.device("cuda" if torch.cuda.is_available() else "cpu") + device = parameters["device"] + + if not parameters.get("root_log_dir"): + parameters["root_log_dir"] = tempfile.mkdtemp() + print(f"No root_log_dir specified, using temporary directory: {parameters['root_log_dir']}") + os.makedirs(parameters["root_log_dir"], exist_ok=True) + + verbose = parameters.get("verbose", True) + log_dir = parameters["root_log_dir"] + tqdm_display = parameters.get("tqdm_display", True) + eval_full_to_train_steps_ratio = parameters.get("eval_full_to_train_steps_ratio", 100) + write_export_ratio = parameters.get("write_export_ratio", 100) + enable_h5_persistence = parameters.get("enable_h5_persistence", True) + training_steps_to_do = parameters.get("training_steps_to_do", 1000) + + # Model (Wide & Deep CTR) + _model = WideDeepCTR( + cardinalities=CATEGORICAL_CARDINALITIES, + num_numeric=NUM_NUMERIC, + emb_dim=parameters.get("model", {}).get("emb_dim", 8), + hidden=parameters.get("model", {}).get("hidden", 128), + num_classes=2, + ).to(device) + model = wl.watch_or_edit(_model, flag="model", device=device) + + # Optimizer + lr = parameters.get("optimizer", {}).get("lr", 0.005) + _optimizer = optim.Adam(model.parameters(), lr=lr) + optimizer = wl.watch_or_edit(_optimizer, flag="optimizer") + + # Data (synthetic CTR impressions) — no download needed. + dataset_cfg = parameters.get("dataset", {}) + seed = int(dataset_cfg.get("seed", 0)) + n_train = int(dataset_cfg.get("n_train", 8000)) + n_test = int(dataset_cfg.get("n_test", 2000)) + + train_cfg = parameters.get("data", {}).get("train_loader", {}) + test_cfg = parameters.get("data", {}).get("test_loader", {}) + + _train_dataset = AdsCTRDataset(n_train, seed=seed, max_samples=train_cfg.get("max_samples")) + _test_dataset = AdsCTRDataset(n_test, seed=seed + 1, max_samples=test_cfg.get("max_samples")) + + train_loader = wl.watch_or_edit( + _train_dataset, + flag="data", + loader_name="train_loader", + batch_size=train_cfg.get("batch_size", 64), + shuffle=train_cfg.get("shuffle", True), + is_training=True, + compute_hash=False, + preload_labels=True, + preload_metadata=True, + enable_h5_persistence=enable_h5_persistence, + ) + test_loader = wl.watch_or_edit( + _test_dataset, + flag="data", + loader_name="test_loader", + batch_size=test_cfg.get("batch_size", 256), + shuffle=test_cfg.get("shuffle", False), + is_training=False, + compute_hash=False, + preload_labels=True, + preload_metadata=True, + enable_h5_persistence=enable_h5_persistence, + ) + + # Losses & metrics. Up-weight clicks (~20% prevalence) so the positive class + # drives the gradient. + class_weights = torch.tensor( + parameters.get("class_weights", [1.0, 4.0]), dtype=torch.float32, device=device + ) + train_criterion = wl.watch_or_edit( + nn.CrossEntropyLoss(weight=class_weights, reduction="none"), + flag="loss", signal_name="train-loss-CE", log=True) + test_criterion = wl.watch_or_edit( + nn.CrossEntropyLoss(weight=class_weights, reduction="none"), + flag="loss", signal_name="test-loss-CE", log=True) + + metric = wl.watch_or_edit( + Accuracy(task="multiclass", num_classes=2).to(device), + flag="metric", signal_name="metric-ACC", log=True) + + wl.serve(serving_grpc=parameters.get("serving_grpc", False)) + + print("=" * 60) + print(" STARTING ADS-CTR TRAINING") + print(f" Evaluation every {eval_full_to_train_steps_ratio} steps") + print(f" Dataset splits: train={len(_train_dataset)}, test={len(_test_dataset)}") + print(f" Logs will be saved to: {log_dir}") + print("=" * 60 + "\n") + + if tqdm_display: + train_range = tqdm.tqdm( + range(training_steps_to_do) if training_steps_to_do is not None else itertools.count(), + desc="Training", + bar_format="{desc}: {n}/{total} [{elapsed}<{remaining}, {rate_fmt}] {bar} | {postfix}", + ncols=140, + position=0, + leave=True, + ) + else: + train_range = range(training_steps_to_do) if training_steps_to_do is not None else itertools.count() + + # ================ + # Training Loop + wl.start_training(timeout=3) + + train_loss = None + test_loss, test_metric = None, None + test_loader_len = len(test_loader) + for train_step in train_range: + age = model.get_age() if hasattr(model, "get_age") else train_step + + train_loss = train(train_loader, model, optimizer, train_criterion, device) + + if age > 0 and age % eval_full_to_train_steps_ratio == 0: + test_loss, test_metric = test( + test_loader, model, test_criterion, metric, device, test_loader_len + ) + + if age > 0 and age % write_export_ratio == 0: + wl.write_history() + wl.write_dataframe() + + if verbose and not tqdm_display: + import sys + msg = f"Step {train_step} (Age {age}): Loss={train_loss:.4f}" + if test_loss is not None: + msg += f" | Test={test_loss:.4f} ({test_metric:.1f}%)" + sys.stdout.write(f"\r{msg:<100}") + sys.stdout.flush() + elif tqdm_display: + postfix_parts = [f"train_loss={train_loss:.4f}"] + if test_loss is not None: + postfix_parts.append(f"test_loss={test_loss:.4f}") + if test_metric is not None: + postfix_parts.append(f"test_acc={test_metric:.1f}%") + train_range.set_postfix_str(" | ".join(postfix_parts)) + + print("\n" + "=" * 60) + print(f" Training completed in {time.time() - start_time:.2f} seconds") + print(f" Logs saved to: {log_dir}") + print("=" * 60) + + wl.write_history() + wl.write_dataframe() + wl.keep_serving() diff --git a/weightslab/examples/PyTorch/wl-ads-recommendation/test_ads_recommendation.py b/weightslab/examples/PyTorch/wl-ads-recommendation/test_ads_recommendation.py new file mode 100644 index 00000000..40a95c86 --- /dev/null +++ b/weightslab/examples/PyTorch/wl-ads-recommendation/test_ads_recommendation.py @@ -0,0 +1,157 @@ +"""Smoke tests for the ads CTR example (pure PyTorch, no weightslab). + +Run: python -m pytest test_ads_recommendation.py + or: python test_ads_recommendation.py +""" + +import os +import sys + +import torch + +sys.path.insert(0, os.path.dirname(__file__)) + +from utils.data import ( # noqa: E402 + CATEGORICAL_CARDINALITIES, + CATEGORICAL_FIELDS, + IMG_SIDE, + NUM_CATEGORICAL, + NUM_FIELDS, + NUM_NUMERIC, + NUMERIC_FIELDS, + AdsCTRDataset, + category_label, + make_synthetic_ctr, + unpack, +) +from utils.model import WideDeepCTR # noqa: E402 + + +def test_schema(): + assert NUM_FIELDS == 16 == IMG_SIDE * IMG_SIDE + assert NUM_CATEGORICAL == len(CATEGORICAL_FIELDS) == len(CATEGORICAL_CARDINALITIES) == 8 + assert NUM_NUMERIC == 8 + + +def test_synthetic_shapes_and_ranges(): + cat, num, y = make_synthetic_ctr(500, seed=0) + assert cat.shape == (500, NUM_CATEGORICAL) + assert num.shape == (500, NUM_NUMERIC) + assert y.shape == (500,) + assert str(cat.dtype) == "int64" and str(num.dtype) == "float32" + for f, card in enumerate(CATEGORICAL_CARDINALITIES): + assert cat[:, f].min() >= 0 and cat[:, f].max() < card + assert set(int(v) for v in set(y.tolist())) <= {0, 1} + + +def test_deterministic_for_seed(): + a = make_synthetic_ctr(200, seed=5) + b = make_synthetic_ctr(200, seed=5) + assert (a[0] == b[0]).all() and (a[1] == b[1]).all() and (a[2] == b[2]).all() + c = make_synthetic_ctr(200, seed=6) + assert not (a[0] == c[0]).all() + + +def test_ctr_is_realistic(): + _, _, y = make_synthetic_ctr(4000, seed=1) + assert 0.15 < float(y.mean()) < 0.26 # calibrated to ~20% CTR + + +def test_dataset_item_contract(): + ds = AdsCTRDataset(300, seed=2) + assert len(ds) == 300 + x, idx, label = ds[0] + # Model input is the packed 1-D field vector (no fake image). + assert tuple(x.shape) == (NUM_FIELDS,) + assert x.dtype == torch.float32 + assert idx == 0 and label in (0, 1) + + +def test_unpack_roundtrip(): + ds = AdsCTRDataset(64, seed=3) + image = torch.stack([ds[i][0] for i in range(len(ds))]) # [N,1,4,4] + cat, num = unpack(image) + assert cat.shape == (len(ds), NUM_CATEGORICAL) + assert num.shape == (len(ds), NUM_NUMERIC) + assert (cat == ds.cat).all(), "categorical indices must survive pack/unpack" + assert torch.allclose(num, ds.num, atol=1e-5) + + +def test_get_items_exposes_field_metadata_columns(): + """The ledger-init contract must expose every field as a metadata column.""" + ds = AdsCTRDataset(50, seed=2) + image, uid, target, metadata = ds.get_items( + 4, include_metadata=True, include_labels=True, include_images=False + ) + assert image is None + assert uid == 4 and target in (0, 1) + assert set(metadata.keys()) == set(CATEGORICAL_FIELDS) | set(NUMERIC_FIELDS) + # Categorical fields are readable strings, numeric are floats. + assert isinstance(metadata["placement"], str) + assert metadata["device_type"] in ("mobile", "desktop", "tablet", "ctv") + assert all(isinstance(metadata[n], float) for n in NUMERIC_FIELDS) + + +def test_category_label_fallback(): + assert category_label("device_type", 0) == "mobile" + assert category_label("region", 3) == "region_3" # no vocab -> fallback + + +def test_model_forward_shape(): + model = WideDeepCTR() + assert model(torch.randn(8, NUM_FIELDS)).shape == (8, 2) + assert model(torch.randn(8, 1, IMG_SIDE, IMG_SIDE)).shape == (8, 2) + + +def test_training_learns_to_rank_clicks(): + """Training must reduce loss and rank real clicks above non-clicks on a + held-out split (train/test share the same ground-truth CTR model).""" + torch.manual_seed(0) + train_ds = AdsCTRDataset(4000, seed=0) + test_ds = AdsCTRDataset(1000, seed=1) + + train_x = torch.stack([train_ds[i][0] for i in range(len(train_ds))]) + train_y = train_ds.labels + test_x = torch.stack([test_ds[i][0] for i in range(len(test_ds))]) + test_y = test_ds.labels + + model = WideDeepCTR() + optimizer = torch.optim.Adam(model.parameters(), lr=5e-3) + # Up-weight the ~20% positive class. + criterion = torch.nn.CrossEntropyLoss(weight=torch.tensor([1.0, 4.0])) + + model.train() + initial_loss = criterion(model(train_x), train_y).item() + batch = 256 + for _ in range(30): + perm = torch.randperm(len(train_ds)) + for s in range(0, len(train_ds), batch): + idx = perm[s:s + batch] + optimizer.zero_grad() + loss = criterion(model(train_x[idx]), train_y[idx]) + loss.backward() + optimizer.step() + final_loss = criterion(model(train_x), train_y).item() + assert final_loss < initial_loss, f"loss did not drop: {initial_loss:.4f} -> {final_loss:.4f}" + + # Ranking quality on the held-out split: P(click) higher for real clicks. + model.eval() + with torch.no_grad(): + prob_click = torch.softmax(model(test_x), dim=1)[:, 1] + pos = prob_click[test_y == 1].mean().item() + neg = prob_click[test_y == 0].mean().item() + assert pos > neg + 0.05, f"model does not separate clicks: pos={pos:.3f} neg={neg:.3f}" + + # Rank-AUC (Mann-Whitney) should beat random (0.5). + with torch.no_grad(): + p = prob_click + pos_p = p[test_y == 1] + neg_p = p[test_y == 0] + wins = (pos_p.unsqueeze(1) > neg_p.unsqueeze(0)).float().mean().item() + assert wins > 0.6, f"AUC too low: {wins:.3f}" + + +if __name__ == "__main__": + import pytest + + raise SystemExit(pytest.main([__file__, "-v"])) diff --git a/weightslab/examples/PyTorch/wl-ads-recommendation/utils/__init__.py b/weightslab/examples/PyTorch/wl-ads-recommendation/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/weightslab/examples/PyTorch/wl-ads-recommendation/utils/data.py b/weightslab/examples/PyTorch/wl-ads-recommendation/utils/data.py new file mode 100644 index 00000000..432203bd --- /dev/null +++ b/weightslab/examples/PyTorch/wl-ads-recommendation/utils/data.py @@ -0,0 +1,224 @@ +"""Synthetic advertising click-through-rate (CTR) dataset (pure PyTorch). + +Import-light (only ``numpy`` + ``torch``) so it can be unit-tested without +importing ``weightslab`` — see ``test_ads_recommendation.py``. + +CTR prediction is the core of an advertising recommendation system: given a +(user, ad, context) triple, predict P(click). This module is a reproducible, +offline stand-in for that task. Each impression has **8 categorical fields** +(user segment, ad category, device, OS, publisher, placement, region, hour +bucket) and **8 numeric features** (ad position, bid, user age, session depth, +historical CTR, …), with a binary ``clicked`` label at a realistic ~20% CTR. + +Real-world analogues (drop-in replaceable): + * Criteo Display Advertising CTR — 13 numeric + 26 categorical fields. + + * Avazu CTR — + * MovieLens (for the recommender variant) — + +Canonical models for this task: Wide & Deep, DeepFM, Factorization Machines. +``utils/model.py`` implements a compact Wide & Deep. + +The 16 field values (8 categorical indices + 8 numeric) are packed into a single +``1x4x4`` tensor per impression so WeightsLab's grid renders a heatmap thumbnail +while the List Exploration view exposes the per-sample stats as sortable columns. +""" + +from __future__ import annotations + +from typing import List, Optional, Tuple + +import numpy as np +import torch +from torch.utils.data import Dataset + +# --- Field schema --------------------------------------------------------- +CATEGORICAL_FIELDS = [ + "user_segment", + "ad_category", + "device_type", + "os", + "publisher", + "placement", + "region", + "hour_bucket", +] +CATEGORICAL_CARDINALITIES = [6, 10, 4, 4, 12, 5, 8, 6] + +NUMERIC_FIELDS = [ + "ad_position", + "bid_price", + "user_age", + "session_depth", + "historical_ctr", + "days_since_last_click", + "num_ads_seen_today", + "creative_freshness", +] + +NUM_CATEGORICAL = len(CATEGORICAL_FIELDS) # 8 +NUM_NUMERIC = len(NUMERIC_FIELDS) # 8 +NUM_FIELDS = NUM_CATEGORICAL + NUM_NUMERIC # 16 +IMG_SIDE = 4 +assert IMG_SIDE * IMG_SIDE == NUM_FIELDS + +# Human-readable labels per categorical field (len == cardinality), used only to +# make the UI metadata columns legible; the model still sees integer codes. +CATEGORICAL_VOCABS = { + "device_type": ["mobile", "desktop", "tablet", "ctv"], + "os": ["android", "ios", "windows", "other"], + "placement": ["banner", "sidebar", "interstitial", "native", "video"], + "hour_bucket": ["night", "early", "morning", "midday", "afternoon", "evening"], +} + + +def category_label(field: str, code: int) -> str: + """Readable label for a categorical code, or ``"_"`` fallback.""" + vocab = CATEGORICAL_VOCABS.get(field) + if vocab is not None and 0 <= code < len(vocab): + return vocab[code] + return f"{field}_{code}" + +TARGET_CTR = 0.20 +# Fixed seed for the *ground-truth* click model, independent of the data seed, +# so train and test splits share the same underlying mapping. +_TRUE_PARAM_SEED = 12345 + + +def _true_params(): + """Stable 'ground-truth' click model parameters (same across all splits).""" + rng = np.random.default_rng(_TRUE_PARAM_SEED) + cat_weights = [rng.normal(0.0, 0.8, size=card) for card in CATEGORICAL_CARDINALITIES] + numeric_weights = rng.normal(0.0, 0.5, size=NUM_NUMERIC) + # One pairwise interaction: ad_category x user_segment (classic in ad CTR). + interaction = rng.normal( + 0.0, 0.7, size=(CATEGORICAL_CARDINALITIES[1], CATEGORICAL_CARDINALITIES[0]) + ) + return cat_weights, numeric_weights, interaction + + +def make_synthetic_ctr( + n_samples: int, seed: int = 0 +) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: + """Generate deterministic synthetic ad impressions. + + Returns ``(cat, num, y)``: + * ``cat``: ``int64[n, NUM_CATEGORICAL]`` category indices + * ``num``: ``float32[n, NUM_NUMERIC]`` standardized numeric features + * ``y``: ``int64[n]`` click label (1 = clicked), ~20% positive + """ + if n_samples <= 0: + return ( + np.zeros((0, NUM_CATEGORICAL), dtype=np.int64), + np.zeros((0, NUM_NUMERIC), dtype=np.float32), + np.zeros((0,), dtype=np.int64), + ) + + rng = np.random.default_rng(seed) + cat_weights, numeric_weights, interaction = _true_params() + + # Sample categorical indices (uniform over each field's cardinality). + cat = np.stack( + [rng.integers(0, card, size=n_samples) for card in CATEGORICAL_CARDINALITIES], + axis=1, + ).astype(np.int64) + + # Sample numeric features ~ N(0, 1) (already "standardized"). + num = rng.normal(0.0, 1.0, size=(n_samples, NUM_NUMERIC)).astype(np.float32) + + # Ground-truth click logit = sum of per-field effects + one interaction + numeric. + logit = np.zeros(n_samples, dtype=np.float64) + for f, w in enumerate(cat_weights): + logit += w[cat[:, f]] + logit += num @ numeric_weights + logit += interaction[cat[:, 1], cat[:, 0]] # ad_category x user_segment + + # Calibrate an additive bias so the *mean click probability* lands near + # TARGET_CTR. Centering the logit mean is not enough (mean(sigmoid) != + # sigmoid(mean) by Jensen), so binary-search the bias on the actual mean prob. + logit -= logit.mean() + lo, hi = -20.0, 20.0 + for _ in range(60): + mid = 0.5 * (lo + hi) + mean_prob = (1.0 / (1.0 + np.exp(-(logit + mid)))).mean() + if mean_prob < TARGET_CTR: + lo = mid + else: + hi = mid + logit += 0.5 * (lo + hi) + + prob = 1.0 / (1.0 + np.exp(-logit)) + y = (rng.uniform(0.0, 1.0, size=n_samples) < prob).astype(np.int64) + + return cat, num, y + + +class AdsCTRDataset(Dataset): + """Advertising CTR dataset yielding ``(image, idx, label)``. + + ``image`` packs the 8 categorical indices (as floats) followed by the 8 + numeric features into ``float32[1, IMG_SIDE, IMG_SIDE]``; the model unpacks + it back into indices + numerics. ``idx`` is the tracked sample id; ``label`` + is 0 (no click) / 1 (click). + """ + + def __init__(self, n_samples: int, seed: int = 0, max_samples: Optional[int] = None): + cat, num, y = make_synthetic_ctr(n_samples, seed=seed) + if max_samples is not None: + cat, num, y = cat[:max_samples], num[:max_samples], y[:max_samples] + packed = np.concatenate([cat.astype(np.float32), num], axis=1) # [N, 16] + self.features = torch.from_numpy(packed) # float32 [N, 16] + self.cat = torch.from_numpy(cat) # int64 [N, 8] + self.num = torch.from_numpy(num) # float32 [N, 8] + self.labels = torch.from_numpy(y) # int64 [N] + + def __len__(self) -> int: + return int(self.features.shape[0]) + + def _input(self, idx: int) -> torch.Tensor: + # Tabular: the model input IS the packed 1-D field vector (no fake image). + return self.features[idx] + + def _metadata(self, idx: int) -> dict: + """Readable per-field values -> sortable UI columns.""" + meta = {} + cat_row = self.cat[idx] + for f, name in enumerate(CATEGORICAL_FIELDS): + meta[name] = category_label(name, int(cat_row[f])) + num_row = self.num[idx] + for j, name in enumerate(NUMERIC_FIELDS): + meta[name] = round(float(num_row[j]), 4) + return meta + + def __getitem__(self, idx: int): + # Training contract: (input, sample_id, label). + return self._input(idx), idx, int(self.labels[idx].item()) + + def get_items(self, idx: int, include_metadata: bool = False, + include_labels: bool = False, include_images: bool = False): + """WeightsLab ledger-init contract: (image, uid, target, metadata). + + The returned ``metadata`` dict (readable categorical labels + numeric + features) is flattened into per-sample columns, so each impression field + (``ad_category``, ``placement``, ``bid_price``, …) becomes a sortable + column in the List Exploration view. + """ + image = self._input(idx) if include_images else None + target = int(self.labels[idx].item()) if include_labels else None + metadata = self._metadata(idx) if include_metadata else None + return image, idx, target, metadata + + +def unpack(x: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]: + """Split a packed ``[N, 16]`` (or ``[N, 1, 4, 4]``) batch into (cat_idx, numeric). + + ``cat_idx`` is ``long[N, 8]`` clamped to valid ranges; ``numeric`` is + ``float[N, 8]``. Shared by the model and the tests so packing lives in one + place. + """ + flat = x.reshape(x.shape[0], -1) + cat = flat[:, :NUM_CATEGORICAL].round().long() + num = flat[:, NUM_CATEGORICAL:] + card = torch.tensor(CATEGORICAL_CARDINALITIES, device=x.device) + cat = cat.clamp(min=torch.zeros_like(card), max=card - 1) + return cat, num diff --git a/weightslab/examples/PyTorch/wl-ads-recommendation/utils/model.py b/weightslab/examples/PyTorch/wl-ads-recommendation/utils/model.py new file mode 100644 index 00000000..dfef847d --- /dev/null +++ b/weightslab/examples/PyTorch/wl-ads-recommendation/utils/model.py @@ -0,0 +1,76 @@ +"""Wide & Deep CTR model for advertising recommendation (pure PyTorch). + +A compact take on the classic Wide & Deep architecture (Cheng et al., 2016): + * **Deep**: each categorical field -> embedding; embeddings are concatenated + with the numeric features and passed through an MLP (learns generalizable + feature interactions). + * **Wide**: first-order per-category effects + a linear numeric term (memorizes + strong direct signals). +The two logits are summed. Outputs 2 logits (no-click / click) so it plugs into +``CrossEntropyLoss`` and a 2-class accuracy metric like the other usecases. +""" + +from __future__ import annotations + +from typing import List + +import torch +import torch.nn as nn + +from .data import ( + CATEGORICAL_CARDINALITIES, + NUM_CATEGORICAL, + NUM_NUMERIC, + IMG_SIDE, + unpack, +) + + +class WideDeepCTR(nn.Module): + def __init__( + self, + cardinalities: List[int] = CATEGORICAL_CARDINALITIES, + num_numeric: int = NUM_NUMERIC, + emb_dim: int = 8, + hidden: int = 128, + num_classes: int = 2, + ): + super().__init__() + self.input_shape = (1, len(cardinalities) + num_numeric) + self.cardinalities = list(cardinalities) + self.num_numeric = num_numeric + + # Deep: one embedding table per categorical field. + self.embeddings = nn.ModuleList( + [nn.Embedding(card, emb_dim) for card in self.cardinalities] + ) + deep_in = emb_dim * len(self.cardinalities) + num_numeric + self.deep = nn.Sequential( + nn.Linear(deep_in, hidden), + nn.ReLU(), + nn.Dropout(0.1), + nn.Linear(hidden, hidden // 2), + nn.ReLU(), + nn.Linear(hidden // 2, num_classes), + ) + + # Wide: first-order per-category effect (embedding dim 1) + linear numeric. + self.wide_cat = nn.ModuleList( + [nn.Embedding(card, num_classes) for card in self.cardinalities] + ) + self.wide_num = nn.Linear(num_numeric, num_classes) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + cat_idx, numeric = unpack(x) # [N, 8] long, [N, 8] float + + # Deep branch. + embs = [emb(cat_idx[:, i]) for i, emb in enumerate(self.embeddings)] + deep_in = torch.cat(embs + [numeric], dim=1) + deep_logits = self.deep(deep_in) + + # Wide branch. + wide_logits = self.wide_num(numeric) + for i, emb in enumerate(self.wide_cat): + wide_logits = wide_logits + emb(cat_idx[:, i]) + + return deep_logits + wide_logits diff --git a/weightslab/examples/PyTorch/wl-ads-recommendation/verify_integration.py b/weightslab/examples/PyTorch/wl-ads-recommendation/verify_integration.py new file mode 100644 index 00000000..b8b08d01 --- /dev/null +++ b/weightslab/examples/PyTorch/wl-ads-recommendation/verify_integration.py @@ -0,0 +1,219 @@ +"""Integration check: prove WeightsLab is fully wired for this tabular example. + +Unlike ``test_ads_recommendation.py`` (pure PyTorch), this drives the WeightsLab +stack end-to-end — tracked dataloaders, watched loss/metric, the gRPC server, +and the per-sample ledger — then asserts the sample dataframe the UI reads +contains, for tabular mode: + + * one row per impression (what you scroll/sort in the grid & List view), + * every ad/user/context field as its own column (from ``get_items`` metadata), + * ``target`` + ``prediction`` populated (so sort/filter/histograms work), + * a per-sample training/eval loss column, + +and that the gRPC server is actually listening (so Weights Studio can connect +live during the run). + +Run: python verify_integration.py +Requires weightslab installed (not a pure-unit test). +""" + +import glob +import os +import socket +import sys +import tempfile +import time as _time + +import torch +import torch.nn as nn +import torch.optim as optim +from torchmetrics.classification import Accuracy + +sys.path.insert(0, os.path.dirname(__file__)) + +import weightslab as wl # noqa: E402 +from weightslab.components.global_monitoring import ( # noqa: E402 + guard_training_context, + guard_testing_context, +) + +from utils.data import ( # noqa: E402 + AdsCTRDataset, + CATEGORICAL_CARDINALITIES, + CATEGORICAL_FIELDS, + NUMERIC_FIELDS, + NUM_NUMERIC, +) +from utils.model import WideDeepCTR # noqa: E402 + + +def _free_port() -> int: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.bind(("127.0.0.1", 0)) + port = s.getsockname()[1] + s.close() + return port + + +def main() -> int: + device = torch.device("cpu") + log_dir = tempfile.mkdtemp(prefix="wl_ads_verify_") + grpc_port = _free_port() + os.environ["GRPC_BACKEND_PORT"] = str(grpc_port) + + parameters = { + "experiment_name": "ads_verify", + "device": "cpu", + "root_log_dir": log_dir, + "serving_grpc": True, + "grpc_port": grpc_port, + "is_training": True, + } + wl.watch_or_edit(parameters, flag="hyperparameters", poll_interval=1.0) + + model = wl.watch_or_edit( + WideDeepCTR(cardinalities=CATEGORICAL_CARDINALITIES, num_numeric=NUM_NUMERIC).to(device), + flag="model", device=device) + optimizer = wl.watch_or_edit(optim.Adam(model.parameters(), lr=0.005), flag="optimizer") + + train_ds = AdsCTRDataset(800, seed=0) + test_ds = AdsCTRDataset(200, seed=1) + + train_loader = wl.watch_or_edit( + train_ds, flag="data", loader_name="train_loader", batch_size=64, shuffle=True, + is_training=True, compute_hash=False, preload_labels=True, preload_metadata=True, + enable_h5_persistence=False) + test_loader = wl.watch_or_edit( + test_ds, flag="data", loader_name="test_loader", batch_size=128, shuffle=False, + is_training=False, compute_hash=False, preload_labels=True, preload_metadata=True, + enable_h5_persistence=False) + + cw = torch.tensor([1.0, 4.0]) + train_crit = wl.watch_or_edit(nn.CrossEntropyLoss(weight=cw, reduction="none"), + flag="loss", signal_name="train-loss-CE", log=True) + test_crit = wl.watch_or_edit(nn.CrossEntropyLoss(weight=cw, reduction="none"), + flag="loss", signal_name="test-loss-CE", log=True) + metric = wl.watch_or_edit(Accuracy(task="multiclass", num_classes=2), + flag="metric", signal_name="metric-ACC", log=True) + + wl.serve(serving_grpc=True, grpc_port=grpc_port) + wl.start_training() + + for _ in range(120): + with guard_training_context: + inputs, ids, labels = next(train_loader) + optimizer.zero_grad() + out = model(inputs) + preds = out.argmax(dim=1, keepdim=True) + loss = train_crit(out, labels, batch_ids=ids, preds=preds).mean() + loss.backward() + optimizer.step() + + for inputs, ids, labels in test_loader: + with guard_testing_context: + out = model(inputs) + preds = out.argmax(dim=1, keepdim=True) + test_crit(out, labels, batch_ids=ids, preds=preds) + metric.update(out, labels) + + wl.drain_signals() + + wl.write_dataframe(path=log_dir, format="csv") + csvs = glob.glob(os.path.join(log_dir, "*dataframe*.csv")) + assert csvs, f"no dataframe CSV written to {log_dir}" + with open(csvs[0], "r", encoding="utf-8") as fh: + header = fh.readline().strip().split(",") + rows = [ln for ln in fh.read().splitlines() if ln.strip()] + + cols = set(c.strip().strip('"') for c in header) + n_rows = len(rows) + + print("\n=== Ledger dataframe ===") + print(f"rows: {n_rows}") + print(f"columns ({len(cols)}): {sorted(cols)}") + + problems = [] + if n_rows < 900: + problems.append(f"expected ~1000 sample rows, got {n_rows}") + + field_cols = set(CATEGORICAL_FIELDS) | set(NUMERIC_FIELDS) + missing_fields = [f for f in field_cols if f not in cols] + if missing_fields: + problems.append(f"missing field columns: {missing_fields}") + + for required in ("target", "prediction"): + if required not in cols: + problems.append(f"missing '{required}' column") + + if not any("loss" in c.lower() for c in cols): + problems.append("no per-sample loss column found") + + grpc_up = False + for _ in range(20): + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(1) + if sock.connect_ex(("127.0.0.1", grpc_port)) == 0: + sock.close() + grpc_up = True + break + sock.close() + _time.sleep(0.5) + if not grpc_up: + problems.append(f"gRPC server not listening on {grpc_port}") + else: + print(f"gRPC server listening on 127.0.0.1:{grpc_port} OK") + + # Real gRPC round-trip: the packed field vector fed to the model must + # reach the UI as raw_data of type 'vector' carrying the values. + try: + import grpc + from weightslab.proto import experiment_service_pb2 as pb2 + from weightslab.proto import experiment_service_pb2_grpc as pb2_grpc + + n_fields = len(CATEGORICAL_CARDINALITIES) + NUM_NUMERIC + channel = grpc.insecure_channel(f"127.0.0.1:{grpc_port}") + grpc.channel_ready_future(channel).result(timeout=10) + stub = pb2_grpc.ExperimentServiceStub(channel) + resp = stub.GetDataSamples(pb2.DataSamplesRequest( + start_index=0, records_cnt=5, include_raw_data=True, + resize_width=0, resize_height=0), timeout=20) + + raw = None + for rec in resp.data_records: + for st in rec.data_stats: + if st.name == "raw_data": + raw = st + break + if raw is not None: + break + + if raw is None: + problems.append("gRPC GetDataSamples returned no raw_data stat") + elif raw.type != "vector": + problems.append(f"raw_data.type is '{raw.type}', expected 'vector'") + elif len(raw.value) != n_fields: + problems.append( + f"raw_data carries {len(raw.value)} values, expected {n_fields}") + else: + print(f"gRPC GetDataSamples: raw_data type='vector', " + f"{len(raw.value)} field values reached the UI OK") + channel.close() + except Exception as e: + problems.append(f"gRPC GetDataSamples failed: {e}") + + if problems: + print("\n VERIFY FAILED:") + for p in problems: + print(f" - {p}") + return 1 + + print("\n VERIFY PASSED: WeightsLab is fully wired for tabular mode " + "(per-sample rows + field columns + target/prediction/loss + live gRPC).") + return 0 + + +if __name__ == "__main__": + code = main() + sys.stdout.flush() + sys.stderr.flush() + os._exit(code) diff --git a/weightslab/examples/PyTorch/wl-fraud-detection/README.md b/weightslab/examples/PyTorch/wl-fraud-detection/README.md new file mode 100644 index 00000000..bd146364 --- /dev/null +++ b/weightslab/examples/PyTorch/wl-fraud-detection/README.md @@ -0,0 +1,92 @@ +# WeightsLab — Bank Fraud Detection (tabular, pure PyTorch) + +[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/GrayboxTech/weightslab/blob/252-tabular-experiment/weightslab/examples/Notebooks/PyTorch/ws-fraud-detection.ipynb) + +A small, fully-runnable **tabular binary-classification** example: an MLP learns +to flag fraudulent bank card transactions, streaming per-sample loss / +prediction / accuracy to the WeightsLab UI. Being tabular, it's the natural +companion to the **List Exploration (tabular) view** — sort by `loss` or +`prediction` to triage the transactions the model finds hardest. + +Everything is plain PyTorch + NumPy — no external download. The dataset is +generated in-process so runs are reproducible and offline. + +## Quick start + +```bash +cd weightslab/examples/PyTorch/wl-fraud-detection +pip install -r requirements.txt +python main.py +``` + +Then open the UI (e.g. `http://localhost:5173`), switch the Data Exploration +board to **List** view, press play, and sort columns to explore. + +## The data + +`make_synthetic_fraud(n, seed)` (in `utils/data.py`) generates a reproducible +stream of transactions. Each row has **16 numeric features** (`amount`, +`old_balance`, `merchant_risk`, `geo_distance_km`, `is_foreign`, +`num_prior_disputes`, …) and a binary label (`0` legit, `1` fraud, ~12% +prevalence). Fraud rows come from shifted distributions (larger amounts, +off-hours activity, higher merchant risk, device changes, larger geo jumps). +Features are standardized and reshaped to a `1x4x4` heatmap so the grid renders +a thumbnail; the model flattens it back to 16. + +### Using a real dataset + +Swap `FraudDataset` for a loader over a real CSV to go from demo to reality: + +* **Kaggle Credit Card Fraud** (ULB) — `creditcard.csv`, 284k transactions with + anonymized `V1..V28` features + `Amount` + `Class`. + +* **PaySim** mobile-money fraud simulator. + + +Keep the `__getitem__` contract `(features_as_image, idx, label)` and adjust +`NUM_FEATURES` / the reshape. + +## What "a sample" is here + +There are no images — **each sample is one transaction (a row)**, and the model +input **is** the 1-D feature vector (not a reshaped image). WeightsLab carries +that vector through gRPC as a `raw_data` stat of type `vector` (the actual +values), so `inputs`, `labels`/`target` and `metadata` all reach the UI. The 16 +raw features are also exposed as **sortable columns** via the dataset's +`get_items()` metadata contract (`preload_metadata=True`), so the List +Exploration view shows real tabular columns (`amount`, `merchant_risk`, +`geo_distance_km`, …) alongside the tracked stats below. Everything you do on +MNIST — sort, lock, histograms, discard/restore, neuron ops — works the same +way, because those operate on the per-sample ledger, not on pixels. + +## What you'll see in the UI + +| Signal / column | Meaning | +| ---------------------------------------- | ---------------------------------------------- | +| feature columns (`amount`, `merchant_risk`, …) | The 16 raw transaction features, sortable | +| `train-loss-CE`, `test-loss-CE` | Weighted cross-entropy per split | +| `metric-ACC` | Overall accuracy | +| `test_metric/Accuracy_per_sample` | Per-transaction correctness (0/1) | +| `test_metric/Fraud_caught_per_sample` | 1 when a true fraud was correctly flagged | +| `target`, `prediction` columns | Per-sample truth/pred to sort/lock in List view | + +Class weights (`[1.0, 4.0]`, config) up-weight the fraud class against the ~12% +prevalence so the minority class drives the gradient. + +## Test it + +```bash +# Fast, offline unit tests (pure PyTorch, no gRPC server): +python -m pytest test_fraud_detection.py -v + +# End-to-end integration check (needs weightslab installed): drives the tracked +# loaders + watched loss + gRPC server, then asserts the ledger dataframe the UI +# reads has per-sample rows, every feature as a column, target/prediction/loss, +# and a live gRPC endpoint. +python verify_integration.py +``` + +The unit tests cover the dataset contract, reproducibility, class balance, the +model forward pass, `get_items` metadata columns, and that a few optimizer steps +reduce the loss (final accuracy > 90%). `verify_integration.py` proves WeightsLab +is fully wired in tabular mode. diff --git a/weightslab/examples/PyTorch/wl-fraud-detection/config.yaml b/weightslab/examples/PyTorch/wl-fraud-detection/config.yaml new file mode 100644 index 00000000..01b5a79c --- /dev/null +++ b/weightslab/examples/PyTorch/wl-fraud-detection/config.yaml @@ -0,0 +1,48 @@ +# Global configuration +experiment_name: fraud_detection_mlp +device: auto +training_steps_to_do: null # null = train until manually stopped from the UI +# root_log_dir: ... # Path to save current experiment checkpoints and data + +# Initially compute natural sorting values (e.g. avg feature magnitude). +compute_natural_sort: false + +# Experiment parameters +eval_full_to_train_steps_ratio: 100 +experiment_dump_to_train_steps_ratio: 250 +write_export_ratio: 100 +skip_checkpoint_load: false +tqdm_display: true +is_training: false # Start paused; press play in the UI. + +# Global dataframe storage +ledger_enable_flushing_threads: true +ledger_enable_h5_persistence: true +ledger_flush_max_rows: 15000 +ledger_flush_interval: 30.0 + +# Clients +serving_grpc: true + +# Optimizer +optimizer: + lr: 0.005 + +# Class weighting to counter ~12% fraud prevalence [legit, fraud]. +class_weights: [1.0, 4.0] + +# Synthetic dataset generation (reproducible, no download). +dataset: + seed: 0 + n_train: 4000 + n_test: 1000 + +# DataLoader parameters +data: + train_loader: + shuffle: true + batch_size: 32 + test_loader: + shuffle: false + batch_size: 128 + drop_last: false diff --git a/weightslab/examples/PyTorch/wl-fraud-detection/main.py b/weightslab/examples/PyTorch/wl-fraud-detection/main.py new file mode 100644 index 00000000..03eb6208 --- /dev/null +++ b/weightslab/examples/PyTorch/wl-fraud-detection/main.py @@ -0,0 +1,284 @@ +"""WeightsLab example: bank card-transaction fraud detection (tabular, PyTorch). + +A small MLP binary classifier over synthetic transaction features, wired into +WeightsLab so the run streams per-sample stats (loss, prediction, target, +discard state) to the UI. Because the data is tabular, this is a natural fit for +the List Exploration (tabular) view — sort by loss or prediction to triage the +transactions the model finds hardest. + +Run: + cd weightslab/examples/PyTorch/wl-fraud-detection + python main.py + +The dataset/model live in ``utils/`` (pure PyTorch) so they can be unit tested +without the gRPC backend — see ``test_fraud_detection.py``. +""" + +import itertools +import os +import time +import logging +import tempfile + +import yaml +import tqdm +import torch +import torch.nn as nn +import torch.optim as optim + +from torchmetrics.classification import Accuracy + +import weightslab as wl +from weightslab.components.global_monitoring import ( + guard_training_context, + guard_testing_context, +) + +from utils.data import FraudDataset, NUM_FEATURES +from utils.model import FraudMLP + + +logging.basicConfig(level=logging.ERROR) +logger = logging.getLogger(__name__) + + +# ----------------------------------------------------------------------------- +# Train / Test steps +# ----------------------------------------------------------------------------- +def train(loader, model, optimizer, criterion_mlt, device): + """Single training step using the tracked dataloader + watched loss.""" + with guard_training_context: + (inputs, ids, labels) = next(loader) + inputs = inputs.to(device) + labels = labels.to(device) + + optimizer.zero_grad() + preds_raw = model(inputs) + preds = preds_raw.argmax(dim=1, keepdim=True) + + loss_batch_mlt = criterion_mlt( + preds_raw.float(), + labels.long(), + batch_ids=ids, + preds=preds, + ) + total_loss = loss_batch_mlt.mean() + + total_loss.backward() + optimizer.step() + + return total_loss.detach().cpu().item() + + +def test(loader, model, criterion_mlt, metric_mlt, device, test_loader_len): + """Full evaluation pass over the test loader, logging per-sample signals.""" + losses = torch.tensor(0.0, device=device) + + for (inputs, ids, labels) in loader: + with guard_testing_context: + inputs = inputs.to(device) + labels = labels.to(device) + + outputs = model(inputs) + preds = outputs.argmax(dim=1, keepdim=True) + + loss_batch = criterion_mlt(outputs, labels, batch_ids=ids, preds=preds) + losses += torch.mean(loss_batch) + metric_mlt.update(outputs, labels) + + preds_flat = preds.view(-1) + labels_flat = labels.view(-1) + acc_per_sample = (preds_flat == labels_flat).float() + fraud_caught_per_sample = ((preds_flat == 1) & (labels_flat == 1)).float() + + signals = { + "test_metric/Accuracy_per_sample": acc_per_sample, + "test_metric/Fraud_caught_per_sample": fraud_caught_per_sample, + } + wl.save_signals( + preds_raw=outputs, + targets=labels, + batch_ids=ids, + signals=signals, + preds=preds, + ) + + loss = losses / max(1, test_loader_len) + metric = metric_mlt.compute() * 100 + + return loss.detach().cpu().item(), metric.detach().cpu().item() + + +# ----------------------------------------------------------------------------- +# Main +# ----------------------------------------------------------------------------- +if __name__ == "__main__": + start_time = time.time() + + # Load hyperparameters (from YAML if present). + parameters = {} + config_path = os.path.join(os.path.dirname(__file__), "config.yaml") + if os.path.exists(config_path): + with open(config_path, "r") as fh: + parameters = yaml.safe_load(fh) or {} + parameters = parameters or {} + + # ---- sensible defaults / normalization ---- + parameters.setdefault("experiment_name", "fraud_detection_mlp") + parameters.setdefault("device", "auto") + parameters.setdefault("training_steps_to_do", 1000000) + parameters.setdefault("eval_full_to_train_steps_ratio", 100) + + exp_name = parameters["experiment_name"] + + # Hyperparameters (must use 'hyperparameters' flag for trainer services / UI). + wl.watch_or_edit(parameters, flag="hyperparameters", poll_interval=1.0) + + # Device selection + if parameters.get("device", "auto") == "auto": + parameters["device"] = torch.device("cuda" if torch.cuda.is_available() else "cpu") + device = parameters["device"] + + # Logging dir + if not parameters.get("root_log_dir"): + parameters["root_log_dir"] = tempfile.mkdtemp() + print(f"No root_log_dir specified, using temporary directory: {parameters['root_log_dir']}") + os.makedirs(parameters["root_log_dir"], exist_ok=True) + + verbose = parameters.get("verbose", True) + log_dir = parameters["root_log_dir"] + tqdm_display = parameters.get("tqdm_display", True) + eval_full_to_train_steps_ratio = parameters.get("eval_full_to_train_steps_ratio", 100) + write_export_ratio = parameters.get("write_export_ratio", 100) + enable_h5_persistence = parameters.get("enable_h5_persistence", True) + training_steps_to_do = parameters.get("training_steps_to_do", 1000) + + # Model + _model = FraudMLP(in_features=NUM_FEATURES, num_classes=2).to(device) + model = wl.watch_or_edit(_model, flag="model", device=device) + + # Optimizer + lr = parameters.get("optimizer", {}).get("lr", 0.005) + _optimizer = optim.Adam(model.parameters(), lr=lr) + optimizer = wl.watch_or_edit(_optimizer, flag="optimizer") + + # Data (synthetic tabular fraud stream) — no download needed. + dataset_cfg = parameters.get("dataset", {}) + seed = int(dataset_cfg.get("seed", 0)) + n_train = int(dataset_cfg.get("n_train", 4000)) + n_test = int(dataset_cfg.get("n_test", 1000)) + + train_cfg = parameters.get("data", {}).get("train_loader", {}) + test_cfg = parameters.get("data", {}).get("test_loader", {}) + + _train_dataset = FraudDataset(n_train, seed=seed, max_samples=train_cfg.get("max_samples")) + _test_dataset = FraudDataset(n_test, seed=seed + 1, max_samples=test_cfg.get("max_samples")) + + train_loader = wl.watch_or_edit( + _train_dataset, + flag="data", + loader_name="train_loader", + batch_size=train_cfg.get("batch_size", 32), + shuffle=train_cfg.get("shuffle", True), + is_training=True, + compute_hash=False, + preload_labels=True, + preload_metadata=True, + enable_h5_persistence=enable_h5_persistence, + ) + test_loader = wl.watch_or_edit( + _test_dataset, + flag="data", + loader_name="test_loader", + batch_size=test_cfg.get("batch_size", 128), + shuffle=test_cfg.get("shuffle", False), + is_training=False, + compute_hash=False, + preload_labels=True, + preload_metadata=True, + enable_h5_persistence=enable_h5_persistence, + ) + + # Losses & metrics (watched objects – they log themselves). + # Class weighting counters the ~12% fraud prevalence so the minority class + # actually drives the gradient. + class_weights = torch.tensor( + parameters.get("class_weights", [1.0, 4.0]), dtype=torch.float32, device=device + ) + train_criterion = wl.watch_or_edit( + nn.CrossEntropyLoss(weight=class_weights, reduction="none"), + flag="loss", signal_name="train-loss-CE", log=True) + test_criterion = wl.watch_or_edit( + nn.CrossEntropyLoss(weight=class_weights, reduction="none"), + flag="loss", signal_name="test-loss-CE", log=True) + + metric = wl.watch_or_edit( + Accuracy(task="multiclass", num_classes=2).to(device), + flag="metric", signal_name="metric-ACC", log=True) + + # Start WeightsLab services (gRPC only, no CLI). + wl.serve(serving_grpc=parameters.get("serving_grpc", False)) + + print("=" * 60) + print(" STARTING FRAUD-DETECTION TRAINING") + print(f" Evaluation every {eval_full_to_train_steps_ratio} steps") + print(f" Dataset splits: train={len(_train_dataset)}, test={len(_test_dataset)}") + print(f" Logs will be saved to: {log_dir}") + print("=" * 60 + "\n") + + if tqdm_display: + train_range = tqdm.tqdm( + range(training_steps_to_do) if training_steps_to_do is not None else itertools.count(), + desc="Training", + bar_format="{desc}: {n}/{total} [{elapsed}<{remaining}, {rate_fmt}] {bar} | {postfix}", + ncols=140, + position=0, + leave=True, + ) + else: + train_range = range(training_steps_to_do) if training_steps_to_do is not None else itertools.count() + + # ================ + # Training Loop + wl.start_training(timeout=3) + + train_loss = None + test_loss, test_metric = None, None + test_loader_len = len(test_loader) + for train_step in train_range: + age = model.get_age() if hasattr(model, "get_age") else train_step + + train_loss = train(train_loader, model, optimizer, train_criterion, device) + + if age > 0 and age % eval_full_to_train_steps_ratio == 0: + test_loss, test_metric = test( + test_loader, model, test_criterion, metric, device, test_loader_len + ) + + if age > 0 and age % write_export_ratio == 0: + wl.write_history() + wl.write_dataframe() + + if verbose and not tqdm_display: + import sys + msg = f"Step {train_step} (Age {age}): Loss={train_loss:.4f}" + if test_loss is not None: + msg += f" | Test={test_loss:.4f} ({test_metric:.1f}%)" + sys.stdout.write(f"\r{msg:<100}") + sys.stdout.flush() + elif tqdm_display: + postfix_parts = [f"train_loss={train_loss:.4f}"] + if test_loss is not None: + postfix_parts.append(f"test_loss={test_loss:.4f}") + if test_metric is not None: + postfix_parts.append(f"test_acc={test_metric:.1f}%") + train_range.set_postfix_str(" | ".join(postfix_parts)) + + print("\n" + "=" * 60) + print(f" Training completed in {time.time() - start_time:.2f} seconds") + print(f" Logs saved to: {log_dir}") + print("=" * 60) + + wl.write_history() + wl.write_dataframe() + wl.keep_serving() diff --git a/weightslab/examples/PyTorch/wl-fraud-detection/test_fraud_detection.py b/weightslab/examples/PyTorch/wl-fraud-detection/test_fraud_detection.py new file mode 100644 index 00000000..21c4e21e --- /dev/null +++ b/weightslab/examples/PyTorch/wl-fraud-detection/test_fraud_detection.py @@ -0,0 +1,118 @@ +"""Smoke tests for the fraud-detection example (pure PyTorch, no weightslab). + +Run: python -m pytest test_fraud_detection.py + or: python test_fraud_detection.py +""" + +import os +import sys + +import torch + +sys.path.insert(0, os.path.dirname(__file__)) + +from utils.data import ( # noqa: E402 + FEATURE_NAMES, + IMG_SIDE, + NUM_FEATURES, + FraudDataset, + make_synthetic_fraud, +) +from utils.model import FraudMLP # noqa: E402 + + +def test_feature_layout_matches_image_grid(): + assert NUM_FEATURES == len(FEATURE_NAMES) == 16 + assert IMG_SIDE * IMG_SIDE == NUM_FEATURES + + +def test_synthetic_shapes_and_labels(): + x_std, x_raw, y = make_synthetic_fraud(500, seed=0) + assert x_std.shape == x_raw.shape == (500, NUM_FEATURES) + assert y.shape == (500,) + assert str(x_std.dtype) == "float32" + assert set(int(v) for v in set(y.tolist())) <= {0, 1} + # Standardized features are ~zero-mean, raw ones are not. + assert abs(float(x_std.mean())) < 0.1 + assert abs(float(x_raw.mean())) > 0.1 + + +def test_synthetic_is_deterministic_for_a_seed(): + x1, _, y1 = make_synthetic_fraud(200, seed=42) + x2, _, y2 = make_synthetic_fraud(200, seed=42) + assert (x1 == x2).all() and (y1 == y2).all() + x3, _, _ = make_synthetic_fraud(200, seed=7) + assert not (x1 == x3).all() + + +def test_class_imbalance_is_realistic(): + _, _, y = make_synthetic_fraud(1000, seed=1) + assert 0.05 < float(y.mean()) < 0.20 + + +def test_dataset_item_contract(): + ds = FraudDataset(300, seed=3) + assert len(ds) == 300 + x, idx, label = ds[0] + # Model input is the 1-D feature vector (no fake image). + assert tuple(x.shape) == (NUM_FEATURES,) + assert x.dtype == torch.float32 + assert idx == 0 and label in (0, 1) + + +def test_get_items_exposes_feature_metadata_columns(): + """The ledger-init contract must return raw features as a metadata dict so + they become sortable columns in the WeightsLab UI.""" + ds = FraudDataset(50, seed=0) + image, uid, target, metadata = ds.get_items( + 3, include_metadata=True, include_labels=True, include_images=False + ) + assert image is None # init does not decode images + assert uid == 3 and target in (0, 1) + assert isinstance(metadata, dict) + assert set(metadata.keys()) == set(FEATURE_NAMES) + assert all(isinstance(v, float) for v in metadata.values()) + # Raw values, not standardized (e.g. amount is a positive currency figure). + assert metadata["amount"] != 0.0 + + +def test_dataset_respects_max_samples(): + assert len(FraudDataset(500, seed=0, max_samples=64)) == 64 + + +def test_model_forward_shape_flat_and_image(): + model = FraudMLP() + assert model(torch.randn(8, NUM_FEATURES)).shape == (8, 2) + assert model(torch.randn(8, 1, IMG_SIDE, IMG_SIDE)).shape == (8, 2) + + +def test_training_reduces_loss(): + torch.manual_seed(0) + ds = FraudDataset(1000, seed=0) + features = ds.features # [N, NUM_FEATURES] + labels = ds.labels + + model = FraudMLP() + optimizer = torch.optim.Adam(model.parameters(), lr=1e-2) + criterion = torch.nn.CrossEntropyLoss() + + model.train() + initial_loss = criterion(model(features), labels).item() + for _ in range(50): + optimizer.zero_grad() + loss = criterion(model(features), labels) + loss.backward() + optimizer.step() + final_loss = criterion(model(features), labels).item() + assert final_loss < initial_loss * 0.85, f"{initial_loss:.4f} -> {final_loss:.4f}" + + model.eval() + with torch.no_grad(): + acc = float((model(features).argmax(dim=1) == labels).float().mean()) + assert acc > 0.9, f"accuracy too low: {acc:.3f}" + + +if __name__ == "__main__": + import pytest + + raise SystemExit(pytest.main([__file__, "-v"])) diff --git a/weightslab/examples/PyTorch/wl-fraud-detection/utils/__init__.py b/weightslab/examples/PyTorch/wl-fraud-detection/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/weightslab/examples/PyTorch/wl-fraud-detection/utils/data.py b/weightslab/examples/PyTorch/wl-fraud-detection/utils/data.py new file mode 100644 index 00000000..ad4537fd --- /dev/null +++ b/weightslab/examples/PyTorch/wl-fraud-detection/utils/data.py @@ -0,0 +1,172 @@ +"""Synthetic bank card-transaction fraud dataset (pure PyTorch). + +Import-light (only ``numpy`` + ``torch``) so it can be unit-tested without +importing ``weightslab`` or starting the gRPC backend — see +``test_fraud_detection.py``. + +This is a reproducible, offline stand-in for a real card-fraud stream. Each row +is a transaction described by 16 numeric features, with a binary label +(0 = legitimate, 1 = fraud, ~12% prevalence). + +Real-world analogues (drop-in replaceable — just swap the dataset): + * Kaggle "Credit Card Fraud Detection" (ULB, ``creditcard.csv``, 284k txns, + anonymized V1..V28 PCA features) — https://www.kaggle.com/mlg-ulb/creditcardfraud + * PaySim mobile-money fraud simulator — https://www.kaggle.com/ealaxi/paysim1 + +The model input is the 1-D feature vector itself (no fake image). WeightsLab +transmits it through gRPC as a ``vector`` raw_data stat carrying the actual +values, and ``get_items`` exposes the raw features as sortable metadata columns +in the List Exploration (tabular) view. +""" + +from __future__ import annotations + +from typing import Optional, Tuple + +import numpy as np +import torch +from torch.utils.data import Dataset + +# 16 features -> a tidy 4x4 grid, no padding needed. +FEATURE_NAMES = [ + "amount", + "old_balance", + "new_balance", + "balance_delta", + "txn_hour", + "txn_day_of_week", + "txn_count_1h", + "txn_count_24h", + "avg_amount_7d", + "std_amount_7d", + "merchant_risk", + "device_change", + "geo_distance_km", + "is_foreign", + "account_age_days", + "num_prior_disputes", +] +NUM_FEATURES = len(FEATURE_NAMES) # 16 +IMG_SIDE = 4 +assert IMG_SIDE * IMG_SIDE == NUM_FEATURES + +FRAUD_RATE = 0.12 + + +def make_synthetic_fraud( + n_samples: int, seed: int = 0 +) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: + """Generate deterministic synthetic transactions. + + Returns ``(X_std, X_raw, y)``: + * ``X_std``: ``float32[n, NUM_FEATURES]`` standardized features (model input) + * ``X_raw``: ``float32[n, NUM_FEATURES]`` raw, human-readable feature values + (surfaced as sortable metadata columns in the UI) + * ``y``: ``int64[n]`` label, 1 = fraud + + Fraud rows are drawn from shifted distributions (larger amounts and balance + deltas, off-hours activity, higher merchant risk, more device changes, larger + geo jumps, more prior disputes). The signal is separable enough that a small + MLP learns it quickly, while class overlap keeps it non-trivial. + """ + if n_samples <= 0: + empty = np.zeros((0, NUM_FEATURES), dtype=np.float32) + return (empty, empty.copy(), np.zeros((0,), dtype=np.int64)) + + rng = np.random.default_rng(seed) + n_fraud = max(1, int(round(n_samples * FRAUD_RATE))) + n_legit = max(1, n_samples - n_fraud) + + def _draw(n: int, fraud: bool) -> np.ndarray: + amount = rng.gamma(2.0, 180.0 if fraud else 60.0, size=n) + old_balance = rng.gamma(2.0, 800.0, size=n) + spent = amount * (rng.uniform(0.6, 1.4, n) if fraud else rng.uniform(0.0, 0.6, n)) + new_balance = np.clip(old_balance - spent, 0, None) + balance_delta = old_balance - new_balance + txn_hour = (rng.normal(2.5, 2.0, n) % 24) if fraud else rng.normal(13.0, 4.0, n) + txn_dow = rng.integers(0, 7, n).astype(np.float64) + txn_count_1h = rng.poisson(4.0 if fraud else 1.0, n).astype(np.float64) + txn_count_24h = rng.poisson(18.0 if fraud else 6.0, n).astype(np.float64) + avg_amount_7d = rng.gamma(2.0, 90.0 if fraud else 70.0, size=n) + std_amount_7d = rng.gamma(2.0, 60.0 if fraud else 25.0, size=n) + merchant_risk = rng.beta(5.0, 2.0, n) if fraud else rng.beta(2.0, 6.0, n) + device_change = rng.binomial(1, 0.55 if fraud else 0.08, n).astype(np.float64) + geo_distance_km = rng.gamma(2.0, 400.0 if fraud else 25.0, size=n) + is_foreign = rng.binomial(1, 0.45 if fraud else 0.05, n).astype(np.float64) + account_age_days = rng.gamma(2.0, 120.0 if fraud else 500.0, size=n) + num_prior_disputes = rng.poisson(1.5 if fraud else 0.2, n).astype(np.float64) + + return np.stack( + [ + amount, old_balance, new_balance, balance_delta, + txn_hour, txn_dow, txn_count_1h, txn_count_24h, + avg_amount_7d, std_amount_7d, merchant_risk, device_change, + geo_distance_km, is_foreign, account_age_days, num_prior_disputes, + ], + axis=1, + ) + + x_raw = np.concatenate([_draw(n_legit, False), _draw(n_fraud, True)], axis=0).astype(np.float64) + y = np.concatenate([np.zeros(n_legit, dtype=np.int64), np.ones(n_fraud, dtype=np.int64)]) + + # Standardize per-feature (class-agnostic) so the MLP trains stably. + mean = x_raw.mean(axis=0, keepdims=True) + std = x_raw.std(axis=0, keepdims=True) + std[std == 0] = 1.0 + x_std = (x_raw - mean) / std + + perm = rng.permutation(len(x_raw)) # interleave fraud/legit deterministically + return ( + x_std[perm].astype(np.float32), + x_raw[perm].astype(np.float32), + y[perm], + ) + + +class FraudDataset(Dataset): + """Tabular fraud dataset yielding ``(input, idx, label)``. + + ``input`` is the 1-D standardized feature vector ``float32[NUM_FEATURES]`` + fed straight to the model — there is no image. WeightsLab transmits the + feature values through gRPC as a ``vector`` raw_data stat, and ``get_items`` + exposes the raw values as sortable metadata columns. ``idx`` is the tracked + sample id; ``label`` is 0 (legit) / 1 (fraud). + """ + + def __init__(self, n_samples: int, seed: int = 0, max_samples: Optional[int] = None): + x_std, x_raw, y = make_synthetic_fraud(n_samples, seed=seed) + if max_samples is not None: + x_std, x_raw, y = x_std[:max_samples], x_raw[:max_samples], y[:max_samples] + self.features = torch.from_numpy(x_std) # [N, 16] float32 (model input) + self.raw = x_raw # [N, 16] float32 (display values) + self.labels = torch.from_numpy(y) # [N] int64 + + def __len__(self) -> int: + return int(self.features.shape[0]) + + def _input(self, idx: int) -> torch.Tensor: + # Tabular: the model input IS the 1-D feature vector (no fake image). + return self.features[idx] + + def _metadata(self, idx: int) -> dict: + """Raw, human-readable feature values -> sortable UI columns.""" + row = self.raw[idx] + return {name: round(float(row[i]), 4) for i, name in enumerate(FEATURE_NAMES)} + + def __getitem__(self, idx: int): + # Training contract: (input, sample_id, label). + return self._input(idx), idx, int(self.labels[idx].item()) + + def get_items(self, idx: int, include_metadata: bool = False, + include_labels: bool = False, include_images: bool = False): + """WeightsLab ledger-init contract: (image, uid, target, metadata). + + Called once per sample at init with ``include_images=False``; the + returned ``metadata`` dict is flattened into per-sample columns, so each + transaction feature (``amount``, ``merchant_risk``, …) becomes a + sortable column in the List Exploration view. + """ + image = self._input(idx) if include_images else None + target = int(self.labels[idx].item()) if include_labels else None + metadata = self._metadata(idx) if include_metadata else None + return image, idx, target, metadata diff --git a/weightslab/examples/PyTorch/wl-fraud-detection/utils/model.py b/weightslab/examples/PyTorch/wl-fraud-detection/utils/model.py new file mode 100644 index 00000000..61503096 --- /dev/null +++ b/weightslab/examples/PyTorch/wl-fraud-detection/utils/model.py @@ -0,0 +1,33 @@ +"""Small MLP binary classifier for tabular fraud detection (pure PyTorch).""" + +from __future__ import annotations + +import torch +import torch.nn as nn + +from .data import NUM_FEATURES, IMG_SIDE + + +class FraudMLP(nn.Module): + """MLP over the 16 transaction features. + + Accepts either flat ``[N, 16]`` or image-shaped ``[N, 1, 4, 4]`` input and + outputs 2 logits (legit / fraud), so it plugs into ``CrossEntropyLoss`` and a + 2-class accuracy metric like the other WeightsLab usecases. + """ + + def __init__(self, in_features: int = NUM_FEATURES, hidden: int = 64, num_classes: int = 2): + super().__init__() + self.input_shape = (1, NUM_FEATURES) + self.net = nn.Sequential( + nn.Flatten(), + nn.Linear(in_features, hidden), + nn.ReLU(), + nn.Dropout(0.1), + nn.Linear(hidden, hidden // 2), + nn.ReLU(), + nn.Linear(hidden // 2, num_classes), + ) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + return self.net(x) diff --git a/weightslab/examples/PyTorch/wl-fraud-detection/verify_integration.py b/weightslab/examples/PyTorch/wl-fraud-detection/verify_integration.py new file mode 100644 index 00000000..93de776f --- /dev/null +++ b/weightslab/examples/PyTorch/wl-fraud-detection/verify_integration.py @@ -0,0 +1,221 @@ +"""Integration check: prove WeightsLab is fully wired for this tabular example. + +Unlike ``test_fraud_detection.py`` (pure PyTorch), this actually drives the +WeightsLab stack end-to-end — tracked dataloaders, watched loss/metric, the +gRPC server, and the per-sample ledger — then asserts the sample dataframe the +UI reads contains, for tabular mode: + + * one row per sample (what you scroll/sort in the grid & List view), + * every transaction feature as its own column (from ``get_items`` metadata), + * ``target`` + ``prediction`` populated (so sort/filter/histograms work), + * a per-sample training/eval loss column, + +and that the gRPC server is actually listening (so Weights Studio can connect +live during the run). + +Run: python verify_integration.py +Requires weightslab installed (not a pure-unit test). +""" + +import glob +import os +import socket +import sys +import tempfile + +import torch +import torch.nn as nn +import torch.optim as optim +from torchmetrics.classification import Accuracy + +sys.path.insert(0, os.path.dirname(__file__)) + +import weightslab as wl # noqa: E402 +from weightslab.components.global_monitoring import ( # noqa: E402 + guard_training_context, + guard_testing_context, +) + +from utils.data import FraudDataset, FEATURE_NAMES, NUM_FEATURES # noqa: E402 +from utils.model import FraudMLP # noqa: E402 + + +def _free_port() -> int: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.bind(("127.0.0.1", 0)) + port = s.getsockname()[1] + s.close() + return port + + +def main() -> int: + device = torch.device("cpu") + log_dir = tempfile.mkdtemp(prefix="wl_fraud_verify_") + grpc_port = _free_port() + # The gRPC server reads its port from GRPC_BACKEND_PORT (same knob the E2E + # harness uses); set it before serve() so we bind to a free ephemeral port. + os.environ["GRPC_BACKEND_PORT"] = str(grpc_port) + + parameters = { + "experiment_name": "fraud_verify", + "device": "cpu", + "root_log_dir": log_dir, + "serving_grpc": True, + "grpc_port": grpc_port, + "is_training": True, + } + wl.watch_or_edit(parameters, flag="hyperparameters", poll_interval=1.0) + + model = wl.watch_or_edit(FraudMLP(in_features=NUM_FEATURES, num_classes=2).to(device), + flag="model", device=device) + optimizer = wl.watch_or_edit(optim.Adam(model.parameters(), lr=0.005), flag="optimizer") + + train_ds = FraudDataset(600, seed=0) + test_ds = FraudDataset(200, seed=1) + + train_loader = wl.watch_or_edit( + train_ds, flag="data", loader_name="train_loader", batch_size=32, shuffle=True, + is_training=True, compute_hash=False, preload_labels=True, preload_metadata=True, + enable_h5_persistence=False) + test_loader = wl.watch_or_edit( + test_ds, flag="data", loader_name="test_loader", batch_size=64, shuffle=False, + is_training=False, compute_hash=False, preload_labels=True, preload_metadata=True, + enable_h5_persistence=False) + + cw = torch.tensor([1.0, 4.0]) + train_crit = wl.watch_or_edit(nn.CrossEntropyLoss(weight=cw, reduction="none"), + flag="loss", signal_name="train-loss-CE", log=True) + test_crit = wl.watch_or_edit(nn.CrossEntropyLoss(weight=cw, reduction="none"), + flag="loss", signal_name="test-loss-CE", log=True) + metric = wl.watch_or_edit(Accuracy(task="multiclass", num_classes=2), + flag="metric", signal_name="metric-ACC", log=True) + + wl.serve(serving_grpc=True, grpc_port=grpc_port) + wl.start_training() + + # ---- drive a few real training steps ---- + for _ in range(120): + with guard_training_context: + inputs, ids, labels = next(train_loader) + optimizer.zero_grad() + out = model(inputs) + preds = out.argmax(dim=1, keepdim=True) + loss = train_crit(out, labels, batch_ids=ids, preds=preds).mean() + loss.backward() + optimizer.step() + + # ---- one full eval pass (populates test loss/prediction per sample) ---- + for inputs, ids, labels in test_loader: + with guard_testing_context: + out = model(inputs) + preds = out.argmax(dim=1, keepdim=True) + test_crit(out, labels, batch_ids=ids, preds=preds) + metric.update(out, labels) + + wl.drain_signals() + + # ---- dump the ledger dataframe the UI reads and inspect it ---- + wl.write_dataframe(path=log_dir, format="csv") + csvs = glob.glob(os.path.join(log_dir, "*dataframe*.csv")) + assert csvs, f"no dataframe CSV written to {log_dir}" + with open(csvs[0], "r", encoding="utf-8") as fh: + header = fh.readline().strip().split(",") + rows = [ln for ln in fh.read().splitlines() if ln.strip()] + + cols = set(c.strip().strip('"') for c in header) + n_rows = len(rows) + + print("\n=== Ledger dataframe ===") + print(f"rows: {n_rows}") + print(f"columns ({len(cols)}): {sorted(cols)}") + + # ---- assertions ---- + problems = [] + if n_rows < 700: # 600 train + 200 test - some may be batched off; expect most + problems.append(f"expected ~800 sample rows, got {n_rows}") + + missing_features = [f for f in FEATURE_NAMES if f not in cols] + if missing_features: + problems.append(f"missing feature columns: {missing_features}") + + for required in ("target", "prediction"): + if required not in cols: + problems.append(f"missing '{required}' column") + + if not any("loss" in c.lower() for c in cols): + problems.append("no per-sample loss column found") + + # gRPC server must be reachable so Weights Studio can connect live. + # It starts on a background thread, so poll for a few seconds. + import time as _time + grpc_up = False + for _ in range(20): + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(1) + if sock.connect_ex(("127.0.0.1", grpc_port)) == 0: + sock.close() + grpc_up = True + break + sock.close() + _time.sleep(0.5) + if not grpc_up: + problems.append(f"gRPC server not listening on {grpc_port}") + else: + print(f"gRPC server listening on 127.0.0.1:{grpc_port} ✓") + + # Real gRPC round-trip: the input the model was fed (the feature vector) + # must reach the UI as raw_data of type 'vector' carrying the values. + try: + import grpc + from weightslab.proto import experiment_service_pb2 as pb2 + from weightslab.proto import experiment_service_pb2_grpc as pb2_grpc + + channel = grpc.insecure_channel(f"127.0.0.1:{grpc_port}") + grpc.channel_ready_future(channel).result(timeout=10) + stub = pb2_grpc.ExperimentServiceStub(channel) + resp = stub.GetDataSamples(pb2.DataSamplesRequest( + start_index=0, records_cnt=5, include_raw_data=True, + resize_width=0, resize_height=0), timeout=20) + + records = list(resp.data_records) + raw = None + for rec in records: + for st in rec.data_stats: + if st.name == "raw_data": + raw = st + break + if raw is not None: + break + + if raw is None: + problems.append("gRPC GetDataSamples returned no raw_data stat") + elif raw.type != "vector": + problems.append(f"raw_data.type is '{raw.type}', expected 'vector'") + elif len(raw.value) != NUM_FEATURES: + problems.append( + f"raw_data carries {len(raw.value)} values, expected {NUM_FEATURES}") + else: + print(f"gRPC GetDataSamples: raw_data type='vector', " + f"{len(raw.value)} feature values reached the UI ✓") + print(f" sample values: {[round(v, 3) for v in raw.value[:6]]} …") + channel.close() + except Exception as e: + problems.append(f"gRPC GetDataSamples failed: {e}") + + if problems: + print("\n VERIFY FAILED:") + for p in problems: + print(f" - {p}") + return 1 + + print("\n VERIFY PASSED: WeightsLab is fully wired for tabular mode " + "(per-sample rows + feature columns + target/prediction/loss + live gRPC).") + return 0 + + +if __name__ == "__main__": + code = main() + sys.stdout.flush() + sys.stderr.flush() + # Hard-exit so background serving threads don't keep the process alive. + os._exit(code) diff --git a/weightslab/examples/README.md b/weightslab/examples/README.md new file mode 100644 index 00000000..c1fc66e6 --- /dev/null +++ b/weightslab/examples/README.md @@ -0,0 +1,57 @@ +# WeightsLab Examples + +Runnable, self-contained examples wired into WeightsLab with the same +`wl.watch_or_edit(...)` / `wl.serve(...)` pattern. Each **card** links to the +example folder (`main.py` + `config.yaml`) and, where available, a one-click +**Google Colab** notebook. + +> Open the UI in a separate terminal to watch any of these live: +> ```bash +> weightslab ui launch # http://localhost:5173 +> ``` + +## Tabular + +The model input is a **feature vector** (no images): a sample is a **row**, and +every feature is a **sortable column** in the List Exploration view. WeightsLab +sends the input vector to the UI through gRPC as a `vector` — see +[the tabular notes](#how-tabular-works) below. + +| Example | Task | Run it | Colab | +| --- | --- | --- | --- | +| [**Bank Fraud Detection**](PyTorch/wl-fraud-detection) | Binary classification on 16 synthetic transaction features (~12% fraud). MLP. | `cd PyTorch/wl-fraud-detection && python main.py` | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/GrayboxTech/weightslab/blob/252-tabular-experiment/weightslab/examples/Notebooks/PyTorch/ws-fraud-detection.ipynb) | +| [**Advertising CTR Recommendation**](PyTorch/wl-ads-recommendation) | Click-through-rate prediction (8 categorical + 8 numeric fields, ~20% CTR). Wide & Deep. | `cd PyTorch/wl-ads-recommendation && python main.py` | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/GrayboxTech/weightslab/blob/252-tabular-experiment/weightslab/examples/Notebooks/PyTorch/ws-ads-recommendation.ipynb) | + +Both datasets are generated in-process (no download). Real drop-in replacements +are documented in each folder's README — Kaggle Credit Card Fraud / PaySim +(fraud); Criteo / Avazu / MovieLens (ads). + +## Computer vision (PyTorch) + +| Example | Task | Run it | Colab | +| --- | --- | --- | --- | +| [Image Classification](PyTorch/wl-classification) | MNIST digit classification. | `weightslab start example --cls` | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/GrayboxTech/weightslab/blob/main/weightslab/examples/Notebooks/PyTorch/ws-classification.ipynb) | +| [Clustering](PyTorch/wl-clustering) | Feature/embedding clustering. | `weightslab start example --clus` | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/GrayboxTech/weightslab/blob/main/weightslab/examples/Notebooks/PyTorch/ws-clustering.ipynb) | +| [Object Detection](PyTorch/wl-detection) | Single-shot detector on Penn-Fudan. | `weightslab start example --det` | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/GrayboxTech/weightslab/blob/main/weightslab/examples/Notebooks/PyTorch/ws-detection.ipynb) | +| [Segmentation](PyTorch/wl-segmentation) | Semantic segmentation (BDD subset). | `weightslab start example --seg` | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/GrayboxTech/weightslab/blob/main/weightslab/examples/Notebooks/PyTorch/ws-segmentation.ipynb) | +| [Generation](PyTorch/wl-generation) | Generative model example. | `weightslab start example --gen` | — | + +## How tabular works + +For tabular examples the dataset: + +1. Returns the **feature vector** from `__getitem__` as the model input — no + fake image. +2. Implements `get_items(idx, include_metadata, include_labels, include_images)` + returning `(input, uid, target, metadata)`; the `metadata` dict becomes one + **sortable column per feature** (loaded via `preload_metadata=True`). + +WeightsLab transmits the input vector to the UI through gRPC as a `raw_data` +stat of `type="vector"` carrying the exact values, alongside `target`, +`prediction`, and the per-feature columns. Sorting, locking, histograms, +discard/restore and neuron ops all work exactly as on image datasets — they +operate on the per-sample ledger, not on pixels. + +Each folder also ships a `verify_integration.py` that drives the full stack +(tracked loaders + watched loss + gRPC) and asserts the feature values reach the +UI over gRPC. diff --git a/weightslab/trainer/services/data_image_utils.py b/weightslab/trainer/services/data_image_utils.py index 81358b32..30695b83 100644 --- a/weightslab/trainer/services/data_image_utils.py +++ b/weightslab/trainer/services/data_image_utils.py @@ -165,6 +165,63 @@ def encode_image_webp(pil_image, quality=70, method=2): return b"" +# ============================================================================= +# Tabular inputs (1-D feature vectors) +# ============================================================================= +# Tabular samples have no image — the model input IS a 1-D feature vector. We +# transmit the actual feature values losslessly in the DataStat ``value`` field +# (type ``"vector"``), and attach a small heatmap so image-only grids still show +# a cell. The List Exploration view reads the per-feature metadata columns; a +# tabular-aware grid can read ``value`` directly. + +def looks_like_tabular(np_img) -> bool: + """True when the sample input is a 1-D feature vector (no spatial dims).""" + return np_img is not None and getattr(np_img, "ndim", None) == 1 + + +def render_tabular_heatmap(vec: np.ndarray, cell: int = 16, quality: int = 60): + """Render a 1-D feature vector as a small square grayscale heatmap. + + Returns ``(webp_bytes, [h, w, c])``. Values are min-max normalised for + display only; the exact values travel in the DataStat ``value`` field. + """ + v = np.asarray(vec, dtype=np.float32).ravel() + n = int(v.size) + if n == 0: + return b"", [0, 0, 0] + side = int(np.ceil(np.sqrt(n))) + padded = np.zeros(side * side, dtype=np.float32) + padded[:n] = v + grid = padded.reshape(side, side) + mn, mx = float(np.nanmin(grid)), float(np.nanmax(grid)) + norm = (grid - mn) / (mx - mn + 1e-8) + img8 = np.clip(norm * 255.0, 0, 255).astype(np.uint8) + try: + pil = Image.fromarray(img8, mode="L").resize( + (side * cell, side * cell), Image.Resampling.NEAREST) + buf = io.BytesIO() + pil.convert("RGB").save(buf, format="WEBP", quality=quality, method=1) + data = buf.getvalue() + buf.close() + return data, [side * cell, side * cell, 3] + except Exception as e: # pragma: no cover - display-only fallback + logger.warning(f"Failed to render tabular heatmap: {e}") + return b"", [side, side, 1] + + +def build_tabular_raw_data_stat(vec: np.ndarray): + """Build the ``raw_data`` DataStat for a tabular (1-D) input. + + The feature values are carried losslessly in ``value`` (type ``"vector"``, + ``shape=[N]``); a heatmap rides along in ``thumbnail`` for display only. + """ + v = np.asarray(vec, dtype=np.float32).ravel() + values = [float(x) for x in v] + thumb, _shape = render_tabular_heatmap(v) + return create_data_stat( + "raw_data", "vector", shape=[len(values)], value=values, thumbnail=thumb) + + def resize_mask_nearest(mask_arr: np.ndarray, target_w: int, target_h: int) -> np.ndarray: """Resize a 2D+ mask array using nearest-neighbour (preserves class IDs). diff --git a/weightslab/trainer/services/data_service.py b/weightslab/trainer/services/data_service.py index 474e7cab..8898505a 100755 --- a/weightslab/trainer/services/data_service.py +++ b/weightslab/trainer/services/data_service.py @@ -46,6 +46,8 @@ rle_encode_mask, create_data_stat, resize_mask_nearest, + looks_like_tabular, + build_tabular_raw_data_stat, ) @@ -493,7 +495,12 @@ def _build_preview_cache(self) -> None: ds_idx = dataset.get_index_from_sample_id(sample_id) member_rank = 0 - _, _, _, pil_img = load_raw_image_array(dataset, ds_idx, rank=member_rank) + _pv_np_img, _, _, pil_img = load_raw_image_array(dataset, ds_idx, rank=member_rank) + if looks_like_tabular(_pv_np_img): + # Tabular sample: cache the feature values (lossless) as a + # 'vector' raw_data stat + heatmap thumbnail, not an image. + stats.append(build_tabular_raw_data_stat(_pv_np_img)) + pil_img = None # skip the image-thumbnail path below if pil_img is not None: ar = pil_img.size[0] / max(pil_img.size[1], 1) if PREVIEW_SIZE >= max(pil_img.size): @@ -1750,7 +1757,12 @@ def _json_default(o): else: np_img, is_volumetric, original_shape, middle_pil = None, False, [], None - if middle_pil is not None: + # Tabular input: the model input is a 1-D feature vector, not an + # image. Transmit the actual values (lossless) as a 'vector' + # raw_data stat instead of a lossy WebP image. + if looks_like_tabular(np_img): + data_stats.append(build_tabular_raw_data_stat(np_img)) + elif middle_pil is not None: original_size = middle_pil.size target_width = original_size[0] target_height = original_size[1]