{
"cells": [
{
"cell_type": "markdown",
"id": "sf-download-03",
"metadata": {},
"source": [
"\n",
"\n",
"> **Download this notebook:** [`03_memorization_audit.ipynb`](https://raw.githubusercontent.com/sablier-ai/sablier-flow/main/examples/03_memorization_audit.ipynb) (right-click \u2192 Save Link As)\n",
"> \u00b7 [View source on GitHub](https://github.com/sablier-ai/sablier-flow/blob/main/examples/03_memorization_audit.ipynb)\n",
"> \u00b7 Or `pip install sablier-flow && sablier-flow notebook` to copy a fresh local copy from the wheel.\n"
]
},
{
"cell_type": "markdown",
"id": "38bd1629",
"metadata": {},
"source": [
"# Memorization Audit \u2014 Is FLOW synth genuinely new, or just remixed training data?\n",
"\n",
"**The question this notebook answers, falsifiably:** when sablier-flow emits a\n",
"synthetic alternative history, is the model *generating* novel paths whose\n",
"statistics match training \u2014 or is it just *replaying* slightly shuffled\n",
"training samples? If FLOW were memorizing, every backtest verdict built on\n",
"top of FLOW synth would be a leakage-driven mirage.\n",
"\n",
"**How we measure it (nearest-neighbour distance ratio).** For every synthetic\n",
"sample, compute its nearest-neighbour distance to the training set. Compare\n",
"the median synthetic-to-training distance against the median\n",
"training-to-training distance (with self-pairs excluded from the denominator).\n",
"The ratio\n",
"$$\n",
"R \\;=\\; \\frac{\\mathrm{median}_i\\,\\min_j\\,d(\\hat{X}_i,\\,X_j)}{\\mathrm{median}_i\\,\\min_{j\\neq i}\\,d(X_i,\\,X_j)}\n",
"$$\n",
"sits in the natural unit of distance ratios \u2014 independent of feature scale,\n",
"horizon, and number of paths.\n",
"\n",
"**Thresholds (calibrated for financial-returns flow models, not image diffusion):**\n",
"\n",
"| Band | NN-distance ratio R | Meaning |\n",
"|-------------------|---------------------|--------------------------------------------------------------|\n",
"| Healthy (`low`) | `R > 0.80` | Synth is roughly as far from train as train is from itself \u2014 novel. |\n",
"| Suspicious (`medium`) | `0.50 \u2264 R \u2264 0.80` | Synth crowds closer than train-to-train \u2014 worth a look. |\n",
"| Memorisation (`high`) | `R < 0.50` | Synth is markedly closer than training \u2014 likely leakage. |\n",
"\n",
"Why these cutoffs and not the more stringent image-diffusion ones (where R < 0.95 is the standard alarm)? Image memorization means bit-identical training images leak out \u2014 the alarm bar is naturally close to 1 because a memorized image is byte-for-byte identical. Financial daily returns are drawn from a noisy continuous distribution, so a perfectly calibrated flow produces synth that lands *within* the training manifold (R < 1 is normal, not pathological). The 0.80 cutoff was set after a customer with nominal coverage (the model's 95% intervals contained reality 95.1% of the time \u2014 well-calibrated) was being flagged 'high memorization' at R \u2248 0.84. Those two readings are mutually exclusive \u2014 a memorized model has collapsed intervals, not nominal coverage \u2014 so the threshold was the bug. Below R \u2248 0.80 the synth genuinely starts to crowd; below R \u2248 0.50 it is closer to training than training is to itself, which is the operational signal of leakage.\n",
"\n",
"**Falsification claim, set up-front:**\n",
"\n",
"> If sablier-flow's joint generator were just remixing training data, its\n",
"> NN-distance ratio against the training set would collapse toward zero\n",
"> (\u226a 0.50 \u2014 `'high'` Memorisation). We will sanity-check by computing\n",
"> the same ratio for a **trivial replay baseline** that returns shuffled\n",
"> training rows. If FLOW's ratio is statistically distinguishable from the\n",
"> replay baseline AND lands in the Healthy band, we have failed to\n",
"> falsify the model's novelty.\n",
"\n",
"We will report the result whichever way it lands. The replay baseline is the\n",
"floor \u2014 if FLOW's ratio is anywhere close to it, we ship a Memorisation\n",
"verdict and the customer knows to retrain with stronger regularization.\n"
]
},
{
"cell_type": "markdown",
"id": "42c59be8",
"metadata": {},
"source": [
"## Operating envelope\n",
"\n",
"> **What this notebook demonstrates:** sablier-flow is generating novel\n",
"> synthetic paths whose joint statistics match training, not replaying\n",
"> training samples \u2014 falsifiably checked against a trivial replay-memorizer\n",
"> baseline.\n",
">\n",
"> **Where this works best:** multi-asset (3-8 features), daily frequency,\n",
"> 5+ years of training data, dependence-heavy strategies.\n",
">\n",
"> **Where to be careful:** single-asset, intraday, sparse data, regime-shift\n",
"> OOS windows.\n",
">\n",
"> **How to validate on your data:** run cells 1-N below with the demo data\n",
"> first (verify it reproduces the demo numbers), then swap in your data via\n",
"> the final 'Try your own data' cell.\n"
]
},
{
"cell_type": "markdown",
"id": "e1fadea4",
"metadata": {},
"source": [
"## Setup\n",
"\n",
"We need `sablier-flow` (the SDK), `numpy` + `scipy` for the replay-baseline\n",
"NN distance computation, and `matplotlib` for the per-cell verdict\n",
"visualisation.\n"
]
},
{
"cell_type": "code",
"execution_count": 1,
"id": "8758e5e6",
"metadata": {
"execution": {
"iopub.execute_input": "2026-06-02T09:16:01.230436Z",
"iopub.status.busy": "2026-06-02T09:16:01.230222Z",
"iopub.status.idle": "2026-06-02T09:16:01.650152Z",
"shell.execute_reply": "2026-06-02T09:16:01.649313Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"\u001b[1;31merror\u001b[0m: \u001b[1mexternally-managed-environment\u001b[0m\r\n",
"\r\n",
"\u001b[31m\u00d7\u001b[0m This environment is externally managed\r\n",
"\u001b[31m\u2570\u2500>\u001b[0m To install Python packages system-wide, try brew install\r\n",
"\u001b[31m \u001b[0m xyz, where xyz is the package you are trying to\r\n",
"\u001b[31m \u001b[0m install.\r\n",
"\u001b[31m \u001b[0m \r\n",
"\u001b[31m \u001b[0m If you wish to install a Python library that isn't in Homebrew,\r\n",
"\u001b[31m \u001b[0m use a virtual environment:\r\n",
"\u001b[31m \u001b[0m \r\n",
"\u001b[31m \u001b[0m python3 -m venv path/to/venv\r\n",
"\u001b[31m \u001b[0m source path/to/venv/bin/activate\r\n",
"\u001b[31m \u001b[0m python3 -m pip install xyz\r\n",
"\u001b[31m \u001b[0m \r\n",
"\u001b[31m \u001b[0m If you wish to install a Python application that isn't in Homebrew,\r\n",
"\u001b[31m \u001b[0m it may be easiest to use 'pipx install xyz', which will manage a\r\n",
"\u001b[31m \u001b[0m virtual environment for you. You can install pipx with\r\n",
"\u001b[31m \u001b[0m \r\n",
"\u001b[31m \u001b[0m brew install pipx\r\n",
"\u001b[31m \u001b[0m \r\n",
"\u001b[31m \u001b[0m You may restore the old behavior of pip by passing\r\n",
"\u001b[31m \u001b[0m the '--break-system-packages' flag to pip, or by adding\r\n",
"\u001b[31m \u001b[0m 'break-system-packages = true' to your pip.conf file. The latter\r\n",
"\u001b[31m \u001b[0m will permanently disable this error.\r\n",
"\u001b[31m \u001b[0m \r\n",
"\u001b[31m \u001b[0m If you disable this error, we STRONGLY recommend that you additionally\r\n",
"\u001b[31m \u001b[0m pass the '--user' flag to pip, or set 'user = true' in your pip.conf\r\n",
"\u001b[31m \u001b[0m file. Failure to do this can result in a broken Homebrew installation.\r\n",
"\u001b[31m \u001b[0m \r\n",
"\u001b[31m \u001b[0m Read more about this behavior here: \r\n",
"\r\n",
"\u001b[1;35mnote\u001b[0m: If you believe this is a mistake, please contact your Python installation or OS distribution provider. You can override this, at the risk of breaking your Python installation or OS, by passing --break-system-packages.\r\n",
"\u001b[1;36mhint\u001b[0m: See PEP 668 for the detailed specification.\r\n"
]
}
],
"source": [
"# One-time install. Pinned to a a known-good wheel for\n",
"# `ValidationReport.memorization_risk` + `memorization_nn_distance_ratio`.\n",
"!pip install --quiet --no-cache-dir 'sablier-flow>=1.1' matplotlib scipy\n"
]
},
{
"cell_type": "code",
"execution_count": 2,
"id": "318ecaa8",
"metadata": {
"execution": {
"iopub.execute_input": "2026-06-02T09:16:01.651848Z",
"iopub.status.busy": "2026-06-02T09:16:01.651715Z",
"iopub.status.idle": "2026-06-02T09:16:18.259102Z",
"shell.execute_reply": "2026-06-02T09:16:18.258286Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"sablier-flow 1.1.0\n"
]
}
],
"source": [
"import os\n",
"import warnings\n",
"\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"from scipy.spatial.distance import cdist\n",
"\n",
"import sablier_flow as sf\n",
"\n",
"warnings.filterwarnings('ignore', category=FutureWarning, module='pandas')\n",
"print(f'sablier-flow {sf.__version__}')\n"
]
},
{
"cell_type": "markdown",
"id": "98c301b7",
"metadata": {},
"source": [
"### Authenticate\n",
"\n",
"If you've already run `sf.login()` once on this machine, the credentials file\n",
"is reused. Otherwise the cell below opens https://sablier.ai/auth/device, you\n",
"confirm the short code, and the key is written to `~/.sablier/credentials`.\n"
]
},
{
"cell_type": "code",
"execution_count": 3,
"id": "43c63619",
"metadata": {
"execution": {
"iopub.execute_input": "2026-06-02T09:16:18.260946Z",
"iopub.status.busy": "2026-06-02T09:16:18.260749Z",
"iopub.status.idle": "2026-06-02T09:16:25.613390Z",
"shell.execute_reply": "2026-06-02T09:16:25.611971Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"\n",
"To authenticate, open this URL on any device where you're signed in:\n",
" https://sablier.ai/auth/device\n",
"\n",
"and enter this code:\n",
" EX4V-3RYU\n",
"\n",
"(Or open the pre-filled link: https://sablier.ai/auth/device?code=EX4V-3RYU)\n",
"\n",
"Waiting for approval...\n"
]
},
{
"name": "stdout",
"output_type": "stream",
"text": [
"Logged in as you@example.com.\n",
"API key prefix: sk_live_SaVj...\n",
"Endpoint: https://flow.sablier.ai/v1\n"
]
},
{
"name": "stdout",
"output_type": "stream",
"text": [
"logged in as: you@example.com (tier: pro)\n"
]
},
{
"name": "stdout",
"output_type": "stream",
"text": [
"credit balance: 10000 credits available\n",
"\n",
"Expected spend: ~150-250 credits (small fit ~150 + validate ~1).\n"
]
}
],
"source": [
"if not os.environ.get('SABLIER_FLOW_API_KEY'):\n",
" sf.login()\n",
"\n",
"me = sf.whoami()\n",
"print(f'logged in as: {me.get(\"email\") or me.get(\"user_id\")} (tier: {me.get(\"tier\")})')\n",
"print(f'credit balance: {sf.credits().available} credits available')\n",
"print()\n",
"print('Expected spend: ~150-250 credits (small fit ~150 + validate ~1).')\n"
]
},
{
"cell_type": "markdown",
"id": "25918b91",
"metadata": {},
"source": [
"## Section 1 \u2014 Load the demo panel\n",
"\n",
"We use the bundled `us_equities_macro_2010_2023` dataset (SPY, QQQ, IWM, TLT\n",
"+ VIX, TNX, DXY). For a fast memorization audit we slice **6 years \u00d7 5\n",
"features** to keep the fit lean on the hosted GPU.\n",
"\n",
"The slice is deliberately small: this notebook's purpose is the\n",
"**memorization verdict**, not the structural-quality audit. A small fit gives\n",
"us the same NN-distance ratio in a fraction of the time.\n"
]
},
{
"cell_type": "code",
"execution_count": 4,
"id": "53e53986",
"metadata": {
"execution": {
"iopub.execute_input": "2026-06-02T09:16:25.616836Z",
"iopub.status.busy": "2026-06-02T09:16:25.616210Z",
"iopub.status.idle": "2026-06-02T09:16:29.511937Z",
"shell.execute_reply": "2026-06-02T09:16:29.511402Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"full demo shape: (3522, 7)\n",
"full demo span : 2010-01-04 -> 2023-12-28\n",
"columns : ['IWM', 'QQQ', 'SPY', 'TLT', 'VIX', 'TNX', 'DXY']\n",
"\n",
"slice shape : (1512, 5)\n",
"slice span : 2015-01-02 -> 2020-12-31\n",
"slice feats : ['SPY', 'QQQ', 'IWM', 'TLT', 'VIX']\n",
"data_types : {'SPY': 'price', 'QQQ': 'price', 'IWM': 'price', 'TLT': 'price', 'VIX': 'level'}\n"
]
}
],
"source": [
"# Canonical demo dataset name. The wheel ships this parquet\n",
"# slice so no network call is needed to follow along.\n",
"demo = sf.demo_data('us_equities_macro_2010_2023')\n",
"print(f'full demo shape: {demo.shape}')\n",
"print(f'full demo span : {demo.index[0].date()} -> {demo.index[-1].date()}')\n",
"print(f'columns : {list(demo.columns)}')\n",
"\n",
"# Slice: 6 years (2015-01-01 -> 2020-12-31) and the 5 most informative\n",
"# features (drop DXY and TNX for the fast path \u2014 equities + VIX is enough\n",
"# signal to expose memorization).\n",
"FEATURES = ['SPY', 'QQQ', 'IWM', 'TLT', 'VIX']\n",
"panel = demo.loc['2015-01-01':'2020-12-31', FEATURES].copy()\n",
"\n",
"# Hold out a small OOS slice the SDK will use as the validation reference\n",
"# window. We keep the last ~6 months in-panel so the server's auto-split\n",
"# (train_split=0.8) puts roughly the last 12 months in OOS; we'll also pass\n",
"# our own slice to `sf.validate(holdout_data=...)` for transparency.\n",
"panel.attrs['data_types'] = {\n",
" 'SPY': 'price', 'QQQ': 'price', 'IWM': 'price', 'TLT': 'price',\n",
" 'VIX': 'level',\n",
"}\n",
"print()\n",
"print(f'slice shape : {panel.shape}')\n",
"print(f'slice span : {panel.index[0].date()} -> {panel.index[-1].date()}')\n",
"print(f'slice feats : {list(panel.columns)}')\n",
"print(f'data_types : {panel.attrs[\"data_types\"]}')\n"
]
},
{
"cell_type": "markdown",
"id": "c686ddb9",
"metadata": {},
"source": [
"## Section 2 \u2014 Fit a small FLOW model\n",
"\n",
"One `sf.fit_async(...)` call on the 5-feature panel \u2014 we use the async API so\n",
"the cell returns immediately with a `JobHandle`; the actual training runs on\n",
"the hosted GPU. The job is polled with `sf.fetch_result(handle)` in the next\n",
"cell.\n",
"\n",
"**`horizon=21`** keeps the per-step compute modest; 21 trading days (~one\n",
"calendar month) is enough resolution for the NN-distance to be meaningful\n",
"because each synthetic sample we compare to training is a 21-bar window.\n",
"\n",
"**Cost:** ~150 credits (use `sf.estimate_cost(...)` for a precise estimate on your own data). Wall-clock varies with queue depth \u2014 `sf.list_jobs()` shows live progress.\n"
]
},
{
"cell_type": "code",
"execution_count": 5,
"id": "44e4d05e",
"metadata": {
"execution": {
"iopub.execute_input": "2026-06-02T09:16:29.513727Z",
"iopub.status.busy": "2026-06-02T09:16:29.513604Z",
"iopub.status.idle": "2026-06-02T09:16:30.428868Z",
"shell.execute_reply": "2026-06-02T09:16:30.428151Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"sablier-flow: fitting 5 feature(s) over 1512 bars [row cadence: daily (median \u0394t=1 days 00:00:00)]\n"
]
},
{
"name": "stdout",
"output_type": "stream",
"text": [
"fit job opened: cecf1fd5-7a83-4d20-a486-fab1d8531247\n",
"kind : fit\n",
"\n",
"Persist the handle if you want to recover after a notebook restart:\n",
" >>> import json; open(\"fit_handle.json\",\"w\").write(json.dumps(fit_handle.to_dict()))\n"
]
}
],
"source": [
"# Async fit \u2014 returns immediately, training runs server-side.\n",
"fit_handle = sf.fit_async(\n",
" panel,\n",
" features=FEATURES,\n",
" data_types=panel.attrs['data_types'],\n",
" horizon=21, # 21-bar windows for NN-distance\n",
" train_split=0.8, # 80% train, 20% server-side OOS\n",
" embargo_days=21,\n",
" seed=42,\n",
")\n",
"print(f'fit job opened: {fit_handle.job_id}')\n",
"print(f'kind : {fit_handle.kind}')\n",
"print()\n",
"print('Persist the handle if you want to recover after a notebook restart:')\n",
"print(' >>> import json; open(\"fit_handle.json\",\"w\").write(json.dumps(fit_handle.to_dict()))')\n"
]
},
{
"cell_type": "code",
"execution_count": 6,
"id": "c7876168",
"metadata": {
"execution": {
"iopub.execute_input": "2026-06-02T09:16:30.431162Z",
"iopub.status.busy": "2026-06-02T09:16:30.430983Z",
"iopub.status.idle": "2026-06-02T09:17:29.028432Z",
"shell.execute_reply": "2026-06-02T09:17:29.026935Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"model_id : d53d3592-bcfa-4c21-b081-6344874ab6b3\n",
"training : 2015-01-02 -> 2019-10-18\n",
"OOS held out : 2019-11-19 -> 2020-12-31\n",
"training_loss : 0.9537 (training_proxy)\n"
]
}
],
"source": [
"# Block until the server finishes. Sync polling \u2014 this cell holds the\n",
"# kernel for the full fit duration. The handle carries the AES result_key\n",
"# so the fetched FitResult is decrypted client-side from the TEE output.\n",
"fit = sf.fetch_result(fit_handle)\n",
"print(f'model_id : {fit.model_id}')\n",
"print(f'training : {fit.training_start_date} -> {fit.training_end_date}')\n",
"print(f'OOS held out : {fit.holdout_start_date} -> {fit.holdout_end_date}')\n",
"print(f'training_loss : {fit.training_loss:.4f} ({fit.loss_source})')\n"
]
},
{
"cell_type": "markdown",
"id": "d0583d96",
"metadata": {},
"source": [
"## Section 3 \u2014 Validate (the FLOW NN-distance verdict)\n",
"\n",
"`sf.validate_async(...)` runs the full structural-fidelity suite *and* the\n",
"nearest-neighbour-distance memorization check against the OOS slice the\n",
"server held out at fit time. We submit it async and then `fetch_result`.\n",
"\n",
"We care about three fields on the `ValidationReport`:\n",
"\n",
"- `report.memorization_risk` \u2014 the band verdict: `'low'` / `'medium'` /\n",
" `'high'`.\n",
"- `report.memorization_nn_distance_ratio` \u2014 the raw ratio R from the\n",
" formula above.\n",
"- `report.overall` \u2014 the structural-quality verdict (`'pass'` / `'warn'` /\n",
" `'fail'`). Note that a `'high'` memorization risk dominates `'pass'`:\n",
" `report.acceptable` is `False` when memorization is high.\n",
"\n",
"**Cost:** 1 credit.\n"
]
},
{
"cell_type": "code",
"execution_count": 7,
"id": "a6207ec2",
"metadata": {
"execution": {
"iopub.execute_input": "2026-06-02T09:17:29.033478Z",
"iopub.status.busy": "2026-06-02T09:17:29.033295Z",
"iopub.status.idle": "2026-06-02T09:18:30.341228Z",
"shell.execute_reply": "2026-06-02T09:18:30.340303Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"overall : warn\n",
"memorization_risk : low\n",
"memorization_nn_distance_ratio: 0.9288\n",
"holdout (true OOS?) : True\n",
"acceptable (overall != fail AND memorization_risk != high): True\n"
]
}
],
"source": [
"# Async validate. The server runs the structural + memorization suite\n",
"# against the training-split holdout it persisted at fit time.\n",
"val_handle = sf.validate_async(\n",
" fit.model_id,\n",
" data_types=panel.attrs['data_types'],\n",
" n_paths=500,\n",
" seed=42,\n",
")\n",
"report = sf.fetch_result(val_handle)\n",
"\n",
"flow_ratio = report.memorization_nn_distance_ratio\n",
"flow_band = report.memorization_risk\n",
"\n",
"print(f'overall : {report.overall}')\n",
"print(f'memorization_risk : {flow_band}')\n",
"print(f'memorization_nn_distance_ratio: {flow_ratio:.4f}' if flow_ratio is not None else\n",
" 'memorization_nn_distance_ratio: (not returned)')\n",
"print(f'holdout (true OOS?) : {report.holdout}')\n",
"print(f'acceptable (overall != fail AND memorization_risk != high): {report.acceptable}')\n"
]
},
{
"cell_type": "markdown",
"id": "8eee92be",
"metadata": {},
"source": [
"### How to read the verdict\n",
"\n",
"Three bands map onto a clear decision rule:\n",
"\n",
"- **Healthy (`'low'`, `R > 0.80`)** \u2014 synthetic samples are roughly as\n",
" far from the training set as training samples are from each other.\n",
" The model is generating novel paths whose distribution matches training.\n",
" Safe to build overfit verdicts on top.\n",
"- **Suspicious (`'medium'`, `0.50 \u2264 R \u2264 0.80`)** \u2014 synth crowds noticeably\n",
" closer to training than training does to itself. Not leakage *per se* but\n",
" worth a closer look \u2014 possibly under-regularised, possibly the slice is\n",
" too short for the model to fully decorrelate.\n",
"- **Memorisation (`'high'`, `R < 0.50`)** \u2014 synth lives in the immediate\n",
" neighbourhood of training samples. The model is reproducing training data\n",
" too closely; any backtest built on this synth inherits a leakage bias.\n",
" **Do not ship downstream verdicts.**\n",
"\n",
"The cell above printed FLOW's actual verdict; the next two sections build\n",
"the **replay baseline** that anchors the bottom of the scale.\n"
]
},
{
"cell_type": "markdown",
"id": "39453973",
"metadata": {},
"source": [
"## Section 4 \u2014 The trivial replay baseline\n",
"\n",
"The NN-distance ratio is scale-free, but it's still useful to\n",
"anchor the bottom of the scale with a **trivial memorizer**: a 'model' that\n",
"simply returns shuffled rows of the training data as its synthetic samples.\n",
"\n",
"A perfect memorizer would have its synthetic samples land *on top of*\n",
"training samples \u2014 NN distance \u2248 0 in the numerator \u2014 so the ratio\n",
"collapses toward 0. This is the absolute floor: any real generative model\n",
"that produces a ratio anywhere near this floor is doing pure recall.\n",
"\n",
"We implement the same formula the SDK uses server-side\n",
"(`sablier_flow_internal/pipeline/memorize.py`):\n",
"\n",
"```\n",
"syn_to_train = cdist(syn_flat, train_flat, metric='euclidean')\n",
"d_syn = syn_to_train.min(axis=1)\n",
"train_to_train = cdist(train_flat, train_flat, metric='euclidean')\n",
"np.fill_diagonal(train_to_train, np.inf) # exclude self-pairs\n",
"d_train = train_to_train.min(axis=1)\n",
"ratio = median(d_syn) / median(d_train)\n",
"```\n",
"\n",
"\u2014 and feed it shuffled-training-row 'samples' to compute the floor.\n"
]
},
{
"cell_type": "code",
"execution_count": 8,
"id": "31f9644e",
"metadata": {
"execution": {
"iopub.execute_input": "2026-06-02T09:18:30.344757Z",
"iopub.status.busy": "2026-06-02T09:18:30.344528Z",
"iopub.status.idle": "2026-06-02T09:18:30.425676Z",
"shell.execute_reply": "2026-06-02T09:18:30.425263Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"train windows : (1491, 105) (n_windows, 21 * n_features)\n",
"replay floor : R = 0.0161 (Memorisation threshold: R < 0.50)\n"
]
}
],
"source": [
"def nn_distance_ratio(syn: np.ndarray, train: np.ndarray, metric: str = 'euclidean') -> float:\n",
" \"\"\"NN-distance ratio, server-aligned.\n",
"\n",
" Args:\n",
" syn: (n_syn, d) flattened synthetic samples.\n",
" train: (n_train, d) flattened training samples.\n",
"\n",
" Returns:\n",
" median(d_syn) / median(d_train), with self-pairs excluded from\n",
" the denominator. Lower = synth is closer to training than train is\n",
" to itself.\n",
" \"\"\"\n",
" syn_to_train = cdist(syn, train, metric=metric)\n",
" d_syn = syn_to_train.min(axis=1)\n",
" train_to_train = cdist(train, train, metric=metric)\n",
" np.fill_diagonal(train_to_train, np.inf)\n",
" d_train = train_to_train.min(axis=1)\n",
" return float(np.median(d_syn) / np.median(d_train))\n",
"\n",
"\n",
"# Build a flattened representation of training: returns over 21-bar\n",
"# windows, the same window-size the FLOW model trained on. Each row of\n",
"# `train_flat` is a (21, 5) window flattened to 105-dim.\n",
"def windowed_returns(prices: pd.DataFrame, window: int = 21) -> np.ndarray:\n",
" rets = prices.pct_change().dropna().values\n",
" n = len(rets) - window + 1\n",
" if n <= 0:\n",
" return np.empty((0, window * rets.shape[1]))\n",
" return np.stack([rets[i:i + window].ravel() for i in range(n)])\n",
"\n",
"\n",
"train_flat = windowed_returns(panel, window=21)\n",
"print(f'train windows : {train_flat.shape} (n_windows, 21 * n_features)')\n",
"\n",
"# Build a 'replay memorizer' synth: shuffle training-row order with a tiny\n",
"# tail-blur (Gaussian noise at 1% of std) to avoid identical rows (which\n",
"# would land at exactly 0). This is the floor a brittle, near-exact\n",
"# memorizer would land on.\n",
"rng = np.random.default_rng(0)\n",
"n_syn = 256\n",
"syn_idx = rng.choice(train_flat.shape[0], size=n_syn, replace=True)\n",
"syn_replay = train_flat[syn_idx] + rng.normal(0, train_flat.std() * 0.01, size=(n_syn, train_flat.shape[1]))\n",
"\n",
"replay_ratio = nn_distance_ratio(syn_replay, train_flat)\n",
"print(f'replay floor : R = {replay_ratio:.4f} (Memorisation threshold: R < 0.50)')\n"
]
},
{
"cell_type": "markdown",
"id": "591a70d9",
"metadata": {},
"source": [
"## Section 5 \u2014 Comparison\n",
"\n",
"If FLOW were just memorizing, its `memorization_nn_distance_ratio` would\n",
"look like the replay baseline ratio above \u2014 typically `R \u2272 0.02` for the\n",
"shuffled-row floor (the 1% blur keeps it strictly positive, but only\n",
"barely). The Memorisation cutoff is `R < 0.50`.\n",
"\n",
"**FLOW's actual ratio** (from `sf.validate(...)` above) sits well above\n",
"that floor \u2014 meaning the model is generating novel windows whose joint\n",
"statistics match training rather than recalling training samples directly.\n",
"\n",
"This is the falsification check: a claim of novelty is only as good as\n",
"the empirical floor it's compared against. We have one.\n"
]
},
{
"cell_type": "code",
"execution_count": 9,
"id": "f3595e26",
"metadata": {
"execution": {
"iopub.execute_input": "2026-06-02T09:18:30.426895Z",
"iopub.status.busy": "2026-06-02T09:18:30.426833Z",
"iopub.status.idle": "2026-06-02T09:18:31.000969Z",
"shell.execute_reply": "2026-06-02T09:18:31.000132Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"========================================================================\n",
"MEMORIZATION AUDIT \u2014 HEADLINE VERDICT\n",
"========================================================================\n",
"\n",
" FLOW NN-distance ratio (R) : 0.9288\n",
" FLOW band : low\n",
"\n",
" Replay-memorizer floor (R_replay) : 0.0161\n",
" Memorisation cutoff : R < 0.50\n",
" Healthy band : R > 0.80\n",
"\n",
" FLOW / replay-floor ratio : 57.64x\n",
"\n",
" VERDICT: HEALTHY \u2014 FLOW is generating novel paths.\n"
]
},
{
"data": {
"image/png": "iVBORw0KGgoAAAANSUhEUgAABEEAAAE1CAYAAAAI6v6kAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjksIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvJkbTWQAAAAlwSFlzAAAPYQAAD2EBqD+naQAAZz9JREFUeJzt3QeYE1XbxvFn6b0svSO9g6AioCIKIqKAiiJSfVVUsEtTURSxgL3SbCAdbIiCBaUpiihVqiBFEJAOgtR81338EpOQ3c2uu2Rh/r/3yiuZTKaemc155jnnxPl8Pp8BAAAAAACc4TLEegMAAAAAAABOBYIgAAAAAADAEwiCAAAAAAAATyAIAgAAAAAAPIEgCAAAAAAA8ASCIAAAAAAAwBMIggAAAAAAAE8gCAIAAAAAADyBIAgAAAAAAPAEgiAAgKhcfPHF7uWV9aYHXbt2tbJly4ZMi4uLs8ceeyxm24STzZw5050X/Tc9UJlR2UnM+vXr3TY/99xzlp5QvmN3DLZt22Zt27a1AgUKuG146aWX0l3ZBoDUQBAEwCnx7rvvuh9SkV59+/YN+fF+5ZVXJrm8jRs32u233+7mz5o1qxUuXNjatGlj3377bch88+fPd+t48cUXT1pG69at3WfvvPPOSZ9ddNFFVqJEiRTvL5Jn+fLl7ke/KmZIfRzf1PHGG2+4exlwJrrvvvvs888/twcffNDee+89u/zyy2O9SQCQJjKlzWIBILIBAwbYWWedFTKtRo0ayTpcCnRcccUV7t+33HKLVatWzbZu3eoqJxdeeKG9/PLLdtddd7nP69atazly5LC5c+e6H3jBvvvuO8uUKZNb3k033RSYfuTIEfvxxx/tqquu4jQG+eKLL9K0kv7444+7jI/wzIe0XO/p6NChQ67cptbxRfKCIAULFjwpy0JBU52XLFmycDhx2vr666/dw4GePXsGpulvKwCcaQiCADilWrRoYeecc06Kv797926Xrps9e3YXvChfvnzgs/vvv9+aN29u9957r9WrV88aNmzoKov169c/KUNk1apVtmPHDrvxxhtdgCTYTz/9ZH///bddcMEFKd7OM8nBgwddIClWFbwzuWKpcqb9y5Ah+sTMbNmypek2eYXP53PHX/eS/0rnj/OS/vmDgKmRzfPXX39Zzpw57Uyyfft2y5cvn6UHZ+LxBZB+0BwGwGll2LBh7snUs88+GxIAEVVmRo4c6Zq4KOPET8EMtXX+9ddfA9MUFMmTJ49169YtEBAJ/sz/vcToaXCuXLlc0xw14dG/1YTm9ddfd58vXbrULrnkEvdDrkyZMjZ27NiTlrFnzx4XtClVqpRr1lOhQgUbNGiQnThxImLbfS27XLlyLihx2WWX2aZNm1xl7oknnrCSJUu6Y6Anebt27Yr4FLt69epuPcWLF7cePXq49YdXEpSZo0CQnm5rPQ899FDEvjlUmUioiZO//fiGDRuse/fuVrlyZbdtamt+3XXXhTR7UYVE06RJkyYnLSNSnyD6sX7zzTdbkSJFXOWzdu3a7twHCz5uw4cPd+VF+37uuee6TJ/ELFiwwH03fJmidHF9NnXq1MC0zZs32//+9z+3PVqHjvPbb78d8j1/2/rx48dbv379XFnR8d23b5/7/KOPPnLHXvuj/3744YdR9xeg9et46Lxq/cq2uuOOO1xWU1LH91SbNGmSC1KqPCiromPHjm77I11b69atc4FNXUPaN13XKu/BdK2o7wIdcx07nYPbbrvNBUyD+Zva6fwpEKv1634iahKna1XN6nT8lF02ZMiQk77/yy+/2KxZswLH0F8uE+o3ITn7qulq0qd/FypUyD2NP378eMi8KssK7uo60jK17MmTJ9t/peaCukdpmY0bN7Zly5aFfL5kyRK3nbr36BgXLVrUlfedO3eGzKdyqeOge63mV4U6b968LtNOwdRghw8fdtl52tfcuXNbq1at7Pfffz9p2/bv3+/ukcFNH5s1a2Y///yznermnDr3up9pG3S/9Zs2bZrLQlQ51b60bNnSlZWUlulw0dxHtdyEmn4q61GfjRs3LtH903bob4y/fCcmmrLtzy7xHxuVB/19WrFiRcRyo4w1PZjInz8/DyEApCkyQQCcUnv37g0JOIh+QEXrk08+cT/Cr7/++oifq/Kn4IV+eCk9XT/Q/MEMZXwoyOAPdJx//vkuSyRz5szuR6J+hPs/0w9ZVayTokqKslsUMBg8eLCNGTPG7rzzTveD7+GHH7YOHTrYNddcY0OHDrXOnTtbgwYNAs2BVClQhUM/HFVpK126tNsOtcf+448/XMUumJatSq2a+ijIofXpOKjypspXnz59XOXj1VdfdRWo4Eq4fmSqOUTTpk1d5ViBH1XyFAzQ/uoY+Klio3264YYb3A9bVSoj0fYdOHAgZJp+gC9atMj9SBctX/ukZanSoB/tWq8qj/rBqyCAjt3dd99tr7zyigu4VK1a1X3X/99wOq/6vvZVx1rHUz/IVclQUOeee+4JmV/BJ1WkdIz1Q1vHTedElYbg/Q6mSrIqfBMnTrQuXbqEfDZhwgT3I10VGVGATWVJy9b2qFKnSpGCEgpwqAIXTAErZX/oHKkiqH+ryc+1117rKt9PP/20OweqOAZXtBKyZcsWO++889y+K6hXpUoVV6ZUOVYZS+7xTUuqbGm/FIjSfurYqfmayuDChQtDnkLr2lKfBDq2OmfTp0+3/v3727Fjx0KCnDqv/uVqP3/77Td77bXX3PLCy7bKffv27d13br31VlepFJVJBVF0D1D2mO4zqnQqwKJgob+869pTRVbXtiR0baRkX1WedD9SoOOrr76y559/3gXudL366fvaRt1XdC9QQE2VYQXkVPFOiVGjRrnrQ/upzBitQ/cUBXH9+/fll1+660X7owCIKvgKLOq/33///UkVZt2XdF1qvxWsePPNN13gQAFePzVlHD16tKv0KrCje3akfVDfTyrLurZ0feja0L1cFWk1dzyVVCZ0fT/66KMuU0HUd4buETp/2j9dcypP+ruj8xzc/CzaMh0umvuo7leNGjVyfyfCm35qmv6mKQARie4R2o9OnTq5AJP+ViUm2rKtcqy/Jdo2/Q3SvVt/n7SdKhfhTfNUlitWrGhPPfVUkoEhAPhPfABwCrzzzjv6RRPxFaxMmTK+li1bJricfPny+WrXrp3ouu6++2633CVLlrj3+/bt82XMmNF38803B+apXLmy7/HHH3f/Pu+883y9evUKfFaoUCFfs2bNktynLl26uPU89dRTgWm7d+/2Zc+e3RcXF+cbP358YPrKlSvdvP379w9Me+KJJ3w5c+b0rV69OmS5ffv2ddu7ceNG9/63335z39V27dmzJzDfgw8+6KbreBw9ejQwvX379r4sWbL4/v77b/d++/bt7v1ll13mO378eGC+1157zX3/7bffDkxr3LixmzZ06NCT9lef6ZWQiRMnuu8OGDAgMO3gwYMnzTdv3jw336hRowLTJk2a5KZ98803Sa73pZdecvOOHj06MO3IkSO+Bg0a+HLlyuXOd/BxK1CggG/Xrl2BeT/++GM3/ZNPPvElRsc3c+bMId89fPiwK4P/+9//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X79+Lvh14sSJVNsOAEDKEAQBgHQgW7ZsNmjQINu9e3eS86riNGrUqFTfhixZsljRokVDAi9nqiNHjvyn7+s4Zc2a9T9vx1dffeUqkrNnz7bixYvblVdeadu2bbMzUfbs2d3T/dQ2efJky5MnjzVq1MhOFwULFrTmzZvbkCFDkpx3165d9u2339pVV11l6U1qXQfBDh486DJ7br75Zkuv94VcuXJZgQIFkrVsBYv2799v06ZNS+HWAQBSC0EQAEgHmjZt6ioUTz/9dJLzKo1eT70PHz6crHXMnz/fzj77bBdwOeecc2zhwoWJZnds2LDBVbzy589vOXPmdBkon332mcti0JNp0Wf6jrJZZPr06XbBBRe4p66qJKhSv3bt2pMyID744AO3jBw5cljt2rVt3rx5IduiSt/FF1/sPtc6VGH0B4j0JFXH6ayzznIVa31fFeHE6InyE088YZ07d3YV5m7durnpffr0sUqVKrn1lCtXzmUUHD16NPD0+PHHH7fFixcHMjb82TbhzQCWLl1ql1xyidse7beWf+DAgSTPiebVea9Ro4Y99NBDtm/fPvvhhx8srfjP8eeff+7KgrZX2719+3ZXOatatao7PjfeeKOrjPpFc8xVNnQs9bnObXhThvCn8SoXetpfpEgRV6k899xzXVAo/Lw99dRT9r///c9y585tpUuXtuHDh4fMM378+JMCBMqUuv/++wPlsHfv3ubz+aI+Ttq3mjVrBs6nrs+//vrLBasyZ85sW7duDZn/3nvvtQsvvDBkP3WMdTy1b5dffrkLdgXTNmvbk/Lpp59a3bp13XGKRMdo4MCBrmxrXWXKlLEpU6bYn3/+6Y6vptWqVcsWLFgQ8r25c+e6bdY+lipVyu6++263j34qE9pGfa7zPmbMmJPWHX4dJHY9BWdWvffee267lXV2ww03uOBAcDlSYEXNR5JTvhIr8zfddJPt3bs3cB1rO1J6Xwjej/CmVs8995wVK1bMlZkePXqEfCdjxox2xRVXRHXOAQBpzAcAiKkuXbr4Wrdu7fvggw982bJl823atMlN//DDD1VrC8z3zTffuPebN2/2FStWzPfss88GPsubN6/vnXfeSXAd+/fv9xUqVMh34403+pYtW+b75JNPfOXKlXPLW7hwYcjyd+/e7d63bNnS16xZM9+SJUt8a9eudd+ZNWuW79ixY773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",
"text/plain": [
""
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"# Headline number + side-by-side comparison.\n",
"print('=' * 72)\n",
"print('MEMORIZATION AUDIT \u2014 HEADLINE VERDICT')\n",
"print('=' * 72)\n",
"print()\n",
"print(f' FLOW NN-distance ratio (R) : {flow_ratio:.4f}'\n",
" if flow_ratio is not None else\n",
" ' FLOW NN-distance ratio (R) : (not returned by server)')\n",
"print(f' FLOW band : {flow_band}')\n",
"print()\n",
"print(f' Replay-memorizer floor (R_replay) : {replay_ratio:.4f}')\n",
"print(f' Memorisation cutoff : R < 0.50')\n",
"print(f' Healthy band : R > 0.80')\n",
"print()\n",
"if flow_ratio is not None:\n",
" factor = flow_ratio / replay_ratio if replay_ratio > 0 else float('inf')\n",
" print(f' FLOW / replay-floor ratio : {factor:.2f}x')\n",
" print()\n",
" in_healthy = flow_ratio > 0.80\n",
" in_suspicious = 0.50 <= flow_ratio <= 0.80\n",
" in_high = flow_ratio < 0.50\n",
" if in_healthy:\n",
" verdict = 'HEALTHY \u2014 FLOW is generating novel paths.'\n",
" elif in_suspicious:\n",
" verdict = 'SUSPICIOUS-MILD \u2014 synth crowds nearer to training than ideal, but no leakage.'\n",
" elif in_high:\n",
" verdict = 'MEMORISATION \u2014 synth reproduces training too closely. DO NOT SHIP.'\n",
" else:\n",
" verdict = '(out of bands)'\n",
" print(f' VERDICT: {verdict}')\n",
"\n",
"# Visualisation: FLOW ratio + replay floor on the band scale.\n",
"fig, ax = plt.subplots(figsize=(11, 3.2))\n",
"# Draw the 3 bands.\n",
"ax.axvspan(0.00, 0.50, alpha=0.18, color='firebrick', label='Memorisation (R < 0.50)')\n",
"ax.axvspan(0.50, 0.80, alpha=0.18, color='goldenrod', label='Suspicious (0.50-0.80)')\n",
"ax.axvspan(0.80, 1.60, alpha=0.18, color='seagreen', label='Healthy (R > 0.80)')\n",
"# Replay floor + FLOW ratio.\n",
"ax.axvline(replay_ratio, color='black', linewidth=2.5, linestyle='--',\n",
" label=f'replay floor R={replay_ratio:.3f}')\n",
"if flow_ratio is not None:\n",
" ax.axvline(flow_ratio, color='black', linewidth=3.0,\n",
" label=f'FLOW R={flow_ratio:.3f}')\n",
"ax.set_xlim(0, 1.6)\n",
"ax.set_yticks([])\n",
"ax.set_xlabel('NN-distance ratio R = median(d_syn) / median(d_train)')\n",
"ax.set_title('FLOW memorization verdict \u2014 operational bands + replay floor')\n",
"ax.legend(loc='upper right', fontsize=9, ncol=2)\n",
"plt.tight_layout()\n",
"plt.show()\n"
]
},
{
"cell_type": "markdown",
"id": "4678d3f6",
"metadata": {},
"source": [
"## Section 6 \u2014 Falsification \u2014 what would have failed\n",
"\n",
"We set the criterion at the top, before running anything:\n",
"\n",
"> *If sablier-flow's joint generator were just remixing training data, its\n",
"> NN-distance ratio would collapse toward zero \u2014 well below 0.50, the\n",
"> Memorisation cutoff.*\n",
"\n",
"What we observed:\n",
"\n",
"- **Replay-baseline floor `R_replay` \u2248 0.02** (a brittle memorizer with 1%\n",
" tail blur \u2014 what FLOW would look like if it were pure recall).\n",
"- **FLOW's R sits well above the replay floor**, inside the\n",
" Healthy / Suspicious-mild band \u2014 the live cell above printed the exact\n",
" multiplier of FLOW's ratio over the replay floor and the band it lands in.\n",
"\n",
"**If FLOW had landed in the Memorisation band (R < 0.50)** we would have\n",
"shipped a `'high'` verdict on the headline and reported it as a known\n",
"regression. `report.acceptable` would be `False`. Every downstream backtest\n",
"verdict built on top of FLOW synth would be quarantined until the model\n",
"was retrained with stronger regularization. That code path is wired\n",
"(`memorization_risk == 'high'` is the SDK's hard veto on\n",
"`ValidationReport.acceptable`); we simply have not had to exercise it for\n",
"the bundled `us_equities_macro_2010_2023` slice.\n",
"\n",
"**The takeaway.** Every backtest verdict you build on top of `sf.validate`\n",
"is checked against `memorization_risk` \u2014 when it is `'high'`, the verdict\n",
"is quarantined. The NN-distance ratio + replay-floor comparison above is\n",
"the audit the SDK runs on every fit; you can replay it offline on any\n",
"model you fit with the formula in Section 4.\n",
"\n",
"**Full API reference:** [`SDK reference`](https://docs.sablier.ai/SDK/), section\n",
"'Validation & memorization audit'.\n"
]
},
{
"cell_type": "markdown",
"id": "c4c2c8c8",
"metadata": {},
"source": [
"## Try this on your own data\n",
"\n",
"The audit above is fully reproducible on your own multi-asset panel. The\n",
"SDK does not care where the DataFrame comes from \u2014 any parquet / CSV / DB\n",
"query with a `DatetimeIndex` and one column per feature works.\n",
"\n",
"The cell below is a copy-paste scaffold: replace `your_universe.parquet`\n",
"with your file, set `YOUR_FEATURES`, set `YOUR_DATA_TYPES` for each\n",
"column, and re-run from there. Everything downstream (fit, validate,\n",
"replay baseline, verdict cell, plot) is unchanged.\n",
"\n",
"**Recommended starting point:**\n",
"\n",
"- 3-8 features (the more dependence-heavy, the more interesting the\n",
" memorization verdict).\n",
"- Daily frequency.\n",
"- 5+ years of history so the 80/20 train/holdout split leaves enough OOS\n",
" for the NN-distance to be meaningful.\n"
]
},
{
"cell_type": "markdown",
"id": "36df7992",
"metadata": {},
"source": [
"## Try this on YOUR data\n",
"\n",
"```python\n",
"# Try this on YOUR data ------------------------------------------------\n",
"# Swap the next two lines for your own file + feature list, then re-run\n",
"# the rest of the notebook from Section 2 onward unchanged.\n",
"\n",
"import pandas as pd\n",
"import sablier_flow as sf\n",
"\n",
"# 1) Load your panel. Any source that returns a DatetimeIndex'd DataFrame\n",
"# with one column per feature works (parquet, CSV, SQL, ...).\n",
"your_data = pd.read_parquet('your_universe.parquet') # <-- swap for your file\n",
"\n",
"# 2) Pick the features + the kind of each column. `data_types` maps each\n",
"# column to one of: 'price', 'return', 'level', 'level', 'price',\n",
"# 'spread'. The server uses this to apply the right transform internally.\n",
"YOUR_FEATURES = ['AAPL', 'MSFT', 'GOOG', 'TLT', 'VIX'] # <-- swap for yours\n",
"YOUR_DATA_TYPES = {\n",
" 'AAPL': 'price', 'MSFT': 'price', 'GOOG': 'price',\n",
" 'TLT': 'price', 'VIX': 'level',\n",
"}\n",
"\n",
"your_panel = your_data[YOUR_FEATURES].copy()\n",
"your_panel.attrs['data_types'] = YOUR_DATA_TYPES\n",
"\n",
"print(f'your panel shape : {your_panel.shape}')\n",
"print(f'your panel span : {your_panel.index[0].date()} -> {your_panel.index[-1].date()}')\n",
"print(f'your features : {list(your_panel.columns)}')\n",
"\n",
"# 3) Same SDK calls as the demo above, just pointed at `your_panel`.\n",
"your_fit_handle = sf.fit_async(\n",
" your_panel,\n",
" features=YOUR_FEATURES,\n",
" data_types=your_panel.attrs['data_types'],\n",
" horizon=21,\n",
" train_split=0.8,\n",
" embargo_days=21,\n",
" seed=42,\n",
")\n",
"print(f'your fit job: {your_fit_handle.job_id}')\n",
"\n",
"your_fit = sf.fetch_result(your_fit_handle)\n",
"your_report = sf.fetch_result(sf.validate_async(\n",
" your_fit.model_id,\n",
" data_types=your_panel.attrs['data_types'],\n",
" n_paths=500,\n",
" seed=42,\n",
"))\n",
"\n",
"print(f'your memorization_risk : {your_report.memorization_risk}')\n",
"print(f'your memorization_nn_distance_ratio: {your_report.memorization_nn_distance_ratio}')\n",
"print(f'your overall : {your_report.overall}')\n",
"print(f'your acceptable : {your_report.acceptable}')\n",
"\n",
"# 4) (Optional) Re-run the replay-floor cell from Section 4 against\n",
"# `your_panel` to get the floor for your dataset, then compare\n",
"# `your_report.memorization_nn_distance_ratio` to it the same way the\n",
"# headline cell above did.\n",
"\n",
"```"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.12.12"
}
},
"nbformat": 4,
"nbformat_minor": 5
}