Files
2026-07-13 13:26:28 +08:00

671 lines
25 KiB
Python

# ---
# jupyter:
# jupytext:
# cell_metadata_filter: tags,-all
# formats: ipynb,py:percent
# text_representation:
# extension: .py
# format_name: percent
# format_version: '1.3'
# jupytext_version: 1.19.3
# kernelspec:
# display_name: Python 3 (ipykernel)
# language: python
# name: python3
# ---
# %% [markdown]
# # Chapter 5: LLM-Based Tabular Data Generation (GReaT Framework)
#
# **Chapter 5: Synthetic Data Generation**
# **Section Reference**: Section 5.6 (LLMs for Structured Financial Data)
#
# **Docker image**: `ml4t-gpu`
#
# > **GPU recommended**: This notebook trains models with PyTorch/CUDA. It will run on CPU
# > but training may be very slow. For GPU acceleration:
# > ```bash
# > docker compose run --rm ml4t-gpu python 05_synthetic_data/06_llm_tabular_great.py
# > ```
#
#
# ## Purpose
#
# This notebook implements **GReaT (Generate Realistic Tabular Data)** using the
# actual `be-great` library to generate synthetic financial tabular data with LLMs.
#
# ## Learning Objectives
#
# By completing this notebook, you will:
# - Understand the serialization insight for applying LLMs to tabular data
# - Fine-tune GPT-2 on serialized financial records using the GReaT framework
# - Generate synthetic tabular data and evaluate fidelity
# - Compare LLM-based generation to traditional methods (GANs, VAEs)
#
# ## Cross-References
#
# - **Book**: Section 5.6 discusses GReaT and LLM-based tabular generation
# - **Related**: [`02_tailgan_tail_risk`](02_tailgan_tail_risk.ipynb) (GAN for time series comparison)
#
# ---
#
# ## Key Concepts
#
# 1. **Serialization**: Convert table rows to natural language sentences
# 2. **Fine-tuning**: Train GPT-2 on serialized financial data
# 3. **Generation**: LLM produces new "sentences" parsed back to table rows
# 4. **Mixed Types**: Handle categorical, numerical, and text features naturally
#
# ## Why LLMs for Tabular Data?
#
# - Captures complex feature dependencies through attention
# - No explicit distribution assumptions
# - Handles mixed data types naturally
# - Pre-trained language understanding helps with feature names
#
# ## Prerequisites
#
# - `be-great` library (`uv add be-great`) for the GReaT framework
# - `transformers` library (installed as a `be-great` dependency)
# - ETF data via `load_etfs()` from Ch2
#
# ## References
#
# - Borisov et al. (2023). "Language Models are Realistic Tabular Data Generators"
# - https://github.com/kathrinse/be_great
# %%
"""LLM-Based Tabular Data Generation — GReaT framework for synthetic financial data."""
# Note: temporal split used instead of train_test_split for financial data
import warnings
from datetime import datetime
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import plotly.graph_objects as go
import polars as pl
from be_great import GReaT
from plotly.subplots import make_subplots
from scipy import stats
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.metrics import accuracy_score, roc_auc_score
from data import load_etfs
from utils.paths import get_output_dir
from utils.reproducibility import set_global_seeds
from utils.style import COLORS, plot_fidelity_comparison
# Suppress transformers warnings
warnings.filterwarnings("ignore", category=FutureWarning)
warnings.filterwarnings("ignore", category=UserWarning, module="transformers")
# %% tags=["parameters"]
# GReaT framework parameters (Borisov et al. 2023)
N_SAMPLES = 2000 # Training samples from ETF data
N_GENERATE = 500 # Synthetic samples to generate
EPOCHS = 50 # Fine-tuning epochs
BATCH_SIZE = 16 # Training batch size
SEED = 42
# %%
set_global_seeds(SEED)
# %%
# Configuration
CONFIG = {
"symbols": None, # Load all ETFs
"start_date": "2015-01-01",
"n_samples": N_SAMPLES,
"n_generate": N_GENERATE,
"epochs": EPOCHS,
"batch_size": BATCH_SIZE,
}
# Checkpoint configuration
RETRAIN = False # Set True to retrain even if checkpoint exists
CHECKPOINT_DIR = get_output_dir(5, "great") / "checkpoints" / "great_model"
# %% [markdown]
# ## 1. Load Real ETF Data
#
# We use actual ETF data to create a tabular dataset suitable for GReaT.
# The function below engineers a mix of numerical features (returns, volatility)
# and categorical features (direction, momentum regime) to showcase GReaT's
# ability to handle mixed-type tabular data.
# %% [markdown]
# ### Build Tabular Feature Set
#
# Each row represents a single observation: lookback features (returns, volatility,
# volume ratio) plus a forward-return target. Categorical columns encode direction,
# momentum strength, and volatility regime -- feature types that GANs struggle with
# but LLMs handle naturally via serialization.
# %%
def load_etf_tabular_data(
symbols: list[str] | None, start_date: str, n_samples: int
) -> pd.DataFrame:
"""
Load ETF data and create tabular features for GReaT.
Creates features that mix numerical (returns, volatility) and
categorical (direction, regime) for realistic tabular generation.
"""
df = load_etfs()
# Determine date column and filter by start date
date_col = "timestamp" if "timestamp" in df.columns else "date"
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
# Cast to date for consistent comparison across schemas
df = df.filter(pl.col(date_col).cast(pl.Date) >= start_dt.date())
if symbols:
df = df.filter(pl.col("symbol").is_in(symbols))
df = df.sort(["symbol", date_col])
# Create tabular features per observation
records = []
for symbol in df["symbol"].unique().to_list():
symbol_df = df.filter(pl.col("symbol") == symbol).sort(date_col)
if len(symbol_df) < 30:
continue
close = symbol_df["close"].to_numpy()
volume = symbol_df["volume"].to_numpy()
dates = symbol_df[date_col].to_list()
for i in range(20, len(close) - 5):
# Lookback features
ret_1d = (close[i] - close[i - 1]) / close[i - 1]
ret_5d = (close[i] - close[i - 5]) / close[i - 5]
ret_20d = (close[i] - close[i - 20]) / close[i - 20]
# Volatility: std of daily returns over 20-day window
window = close[i - 20 : i]
daily_rets = window[1:] / window[:-1] - 1.0
vol_20d = daily_rets.std()
vol_ratio = volume[i] / np.mean(volume[i - 20 : i])
# Forward return (target)
fwd_ret_5d = (close[i + 5] - close[i]) / close[i]
# Categorical features
direction = "up" if ret_1d > 0 else "down"
momentum = (
"strong" if abs(ret_20d) > 0.05 else "weak" if abs(ret_20d) > 0.02 else "flat"
)
vol_regime = "high" if vol_20d > 0.02 else "normal" if vol_20d > 0.01 else "low"
# Forward return absolute value for extreme move classification
abs_fwd_ret_5d = abs(fwd_ret_5d)
records.append(
{
"timestamp": dates[i], # For temporal split
"symbol": symbol,
"ret_1d": round(ret_1d * 100, 2), # Percentage
"ret_5d": round(ret_5d * 100, 2),
"ret_20d": round(ret_20d * 100, 2),
"volatility": round(vol_20d * 100, 2),
"volume_ratio": round(vol_ratio, 2),
"direction": direction,
"momentum": momentum,
"vol_regime": vol_regime,
"fwd_ret_5d": round(fwd_ret_5d * 100, 2),
"abs_fwd_ret_5d": round(abs_fwd_ret_5d * 100, 2), # For extreme move
"target": None, # Will be computed after all rows
}
)
result_df = pd.DataFrame(records)
# Sort by date for proper temporal split (critical for financial data)
result_df = result_df.sort_values("timestamp").reset_index(drop=True)
# Compute extreme move target: |fwd_ret| > 90th percentile
# This exploits volatility clustering which has real predictive signal
threshold = result_df["abs_fwd_ret_5d"].quantile(0.90)
result_df["target"] = (result_df["abs_fwd_ret_5d"] > threshold).astype(int)
# Truncate if too many (preserve temporal order)
if len(result_df) > n_samples:
result_df = result_df.head(n_samples)
return result_df.reset_index(drop=True)
# %% [markdown]
# ### Load and Inspect
#
# Load the ETF feature table and verify the mix of numerical and categorical columns.
# %%
print("Loading real ETF data...")
df = load_etf_tabular_data(CONFIG["symbols"], CONFIG["start_date"], CONFIG["n_samples"])
print(f"Loaded {len(df)} samples with {len(df.columns)} features")
print(f"\nFeature types:\n{df.dtypes}")
print("\nSample rows:")
print(df.head())
# %% [markdown]
# ## 2. GReaT: LLM-Based Generation
#
# Using the actual `be-great` library with distilgpt2 for fast training.
# %%
# Prepare training data (drop symbol and date to avoid memorization)
train_df = df.drop(columns=["symbol", "timestamp"])
# Check for existing checkpoint
checkpoint_exists = CHECKPOINT_DIR.exists() and (CHECKPOINT_DIR / "config.json").exists()
if checkpoint_exists and not RETRAIN:
print(f"\nLoading GReaT model from checkpoint: {CHECKPOINT_DIR}")
great = GReaT.load_from_dir(str(CHECKPOINT_DIR))
print("Checkpoint loaded successfully!")
else:
if RETRAIN and checkpoint_exists:
print("\nRETRAIN=True, retraining despite existing checkpoint...")
else:
print("\nNo checkpoint found, training from scratch...")
print("Initializing GReaT with distilgpt2...")
# Use distilgpt2 (82M parameters) — smaller LLM keeps fine-tuning tractable
# on CPU/single-GPU. Larger backbones (GPT-2 medium/large, LLaMA) trade
# compute for fidelity but do not change the GReaT serialization pipeline.
great = GReaT(
llm="distilgpt2",
batch_size=CONFIG["batch_size"],
epochs=CONFIG["epochs"],
experiment_dir=str(get_output_dir(5, "great") / "trainer_great"),
save_steps=5000, # Don't save intermediate checkpoints
logging_steps=100,
)
print(f"Training on {len(train_df)} samples...")
print(f"Epochs: {CONFIG['epochs']}, Batch size: {CONFIG['batch_size']}")
# Train the model
great.fit(train_df)
# Save checkpoint
CHECKPOINT_DIR.mkdir(parents=True, exist_ok=True)
great.save(str(CHECKPOINT_DIR))
print(f"\nCheckpoint saved to: {CHECKPOINT_DIR}")
print("\nTraining complete!")
# %% [markdown]
# ## 3. Generate Synthetic Data
# %%
print(f"\nGenerating {CONFIG['n_generate']} synthetic samples...")
# Generate synthetic data — guided sampling enforces the column schema row by
# row and is essential when fine-tuning is short (the unguided sampler tends
# to drop columns on undertrained models). With well-trained checkpoints,
# guided_sampling=False is faster and produces equivalent quality.
synthetic_df = great.sample(
n_samples=CONFIG["n_generate"],
max_length=500,
guided_sampling=True,
)
print(f"Generated {len(synthetic_df)} samples")
print("\nSample synthetic rows:")
print(synthetic_df.head())
# Check for parsing errors (NaN values)
nan_counts = synthetic_df.isna().sum()
if nan_counts.sum() > 0:
print(f"\nParsing issues (NaN counts):\n{nan_counts[nan_counts > 0]}")
# %% [markdown]
# **Observation**: The generated rows should contain plausible feature values -- returns
# near zero with occasional larger moves, volatility in realistic ranges, and valid
# categorical labels. NaN counts above zero indicate parsing failures where the LLM
# produced text that could not be mapped back to the original schema. This is a known
# limitation of autoregressive generation: the model can "hallucinate" tokens that
# break column parsing, especially with short fine-tuning. Increasing epochs and using
# larger base models (GPT-2 medium/large) reduces parsing errors significantly.
# %% [markdown]
# ## 4. Fidelity: Visual Comparison with PCA and t-SNE
#
# We project both real and synthetic data into 2D using only numerical features
# to assess whether the generator covers the same regions of the data manifold.
# %%
# Extract numerical columns for visualization
numerical_cols = ["ret_1d", "ret_5d", "ret_20d", "volatility", "volume_ratio", "fwd_ret_5d"]
available_cols = [c for c in numerical_cols if c in df.columns and c in synthetic_df.columns]
# Convert to numpy arrays (handling potential NaN from LLM parsing errors)
real_data = df[available_cols].dropna().values
synth_data = synthetic_df[available_cols].apply(pd.to_numeric, errors="coerce").dropna().values
if len(synth_data) >= 50: # Need enough samples for meaningful visualization
fig = plot_fidelity_comparison(
real_data,
synth_data,
title="GReaT: Real vs Synthetic Distribution",
n_samples=min(500, len(synth_data)),
)
plt.show()
else:
print(f"Insufficient valid synthetic samples ({len(synth_data)}) for fidelity visualization")
# %% [markdown]
# **Interpretation**: Overlapping point clouds confirm that GReaT-generated tabular
# data occupies similar regions of feature space as real data. LLM-based generation
# can capture complex feature dependencies via autoregressive modeling. Gaps may
# indicate parsing errors or LLM hallucination on certain feature combinations.
# %% [markdown]
# ## 5. Compare Real vs Synthetic Distributions
# %%
compare_cols = [c for c in df.columns if c != "asset" and c in synthetic_df.columns]
numerical_cols = ["ret_1d", "ret_5d", "ret_20d", "volatility", "volume_ratio", "fwd_ret_5d"]
categorical_cols = ["direction", "momentum", "vol_regime"]
numerical_rows = []
for col in numerical_cols:
if col in synthetic_df.columns:
real_vals = df[col].dropna()
synth_vals = pd.to_numeric(synthetic_df[col], errors="coerce").dropna()
if len(synth_vals) > 0:
numerical_rows.append(
{
"feature": col,
"real_mean": real_vals.mean(),
"synth_mean": synth_vals.mean(),
"real_std": real_vals.std(),
"synth_std": synth_vals.std(),
}
)
numerical_comparison = pd.DataFrame(numerical_rows).set_index("feature").round(3)
numerical_comparison
# %%
categorical_rows = []
for col in categorical_cols:
if col in synthetic_df.columns:
real_dist = df[col].value_counts(normalize=True)
synth_dist = synthetic_df[col].value_counts(normalize=True)
for cat in real_dist.index:
categorical_rows.append(
{
"feature": col,
"category": cat,
"real_pct": real_dist.get(cat, 0) * 100,
"synth_pct": synth_dist.get(cat, 0) * 100,
}
)
categorical_comparison = pd.DataFrame(categorical_rows).round(1)
categorical_comparison
# %% [markdown]
# ## 5. Visualize Distributions
# %%
# Numerical distributions
fig = make_subplots(
rows=2,
cols=3,
subplot_titles=[
"1-Day Return",
"5-Day Return",
"20-Day Return",
"Volatility",
"Volume Ratio",
"Fwd 5D Return",
],
)
plot_cols = ["ret_1d", "ret_5d", "ret_20d", "volatility", "volume_ratio", "fwd_ret_5d"]
positions = [(1, 1), (1, 2), (1, 3), (2, 1), (2, 2), (2, 3)]
for idx, (col, (row, col_num)) in enumerate(zip(plot_cols, positions, strict=False)):
if col in synthetic_df.columns:
synth_vals = pd.to_numeric(synthetic_df[col], errors="coerce").dropna()
showlegend = idx == 0 # only one legend entry per series
fig.add_trace(
go.Histogram(
x=df[col],
name="Real",
opacity=0.6,
marker_color=COLORS["blue"],
nbinsx=30,
showlegend=showlegend,
legendgroup="real",
),
row=row,
col=col_num,
)
fig.add_trace(
go.Histogram(
x=synth_vals,
name="Synthetic",
opacity=0.6,
marker_color=COLORS["amber"],
nbinsx=30,
showlegend=showlegend,
legendgroup="synthetic",
),
row=row,
col=col_num,
)
fig.update_yaxes(title_text="Density (count)")
fig.update_xaxes(title_text="Feature value")
fig.update_layout(
title="Real vs GReaT Synthetic Distributions",
height=500,
showlegend=True,
barmode="overlay",
template="ml4t",
)
fig.show()
# %% [markdown]
# ## 6. TSTR Evaluation: Train Synthetic, Test Real
#
# The key test: Can a model trained on GReaT synthetic data predict real outcomes?
#
# **Task**: Extreme move classification (|fwd_ret_5d| > 90th percentile)
# - Exploits volatility clustering which has real predictive signal (~0.78 AUC)
# - Unlike direction prediction (~0.50 AUC), this provides meaningful comparisons
# %% [markdown]
# ### Prepare Features and Temporal Split
#
# We use a temporal split (first 70% train, last 30% test) rather than random
# splitting. This avoids data leakage from future observations contaminating
# the training set -- a critical requirement for financial time series.
# %%
print("\n" + "=" * 70)
print("TRAIN-SYNTHETIC-TEST-REAL (TSTR) EVALUATION")
print("Task: Extreme move classification (|fwd_ret_5d| > 90th percentile)")
print("=" * 70)
# Prepare features
feature_cols = ["ret_1d", "ret_5d", "ret_20d", "volatility", "volume_ratio"]
target_col = "target"
# Real data - use temporal split (not random) for financial data
# Sort by symbol to ensure temporal ordering within each group is preserved
# Then take first 70% for training, last 30% for testing
X_real = df[feature_cols].values
y_real = df[target_col].values
# Temporal split: avoid mixing future and past observations in train/test
n_train = int(len(X_real) * 0.7)
X_train_real, X_test = X_real[:n_train], X_real[n_train:]
y_train_real, y_test = y_real[:n_train], y_real[n_train:]
# Synthetic data - need to handle potential parsing issues
synth_features = synthetic_df[feature_cols].apply(pd.to_numeric, errors="coerce")
synth_target = pd.to_numeric(synthetic_df[target_col], errors="coerce")
# Drop rows with NaN and convert target to binary
valid_mask = ~(synth_features.isna().any(axis=1) | synth_target.isna())
X_synth = synth_features[valid_mask].values
y_synth_raw = synth_target[valid_mask].values
# Convert to binary: round and clip to 0/1
y_synth = np.clip(np.round(y_synth_raw), 0, 1).astype(int)
print(f"\nReal training samples: {len(X_train_real)}")
print(f"Synthetic training samples: {len(X_synth)}")
print(f"Test samples: {len(X_test)}")
# %% [markdown]
# ### Train and Compare Models
#
# We train two identical gradient boosting classifiers -- one on real data (TRTR
# baseline) and one on synthetic data (TSTR). The TSTR accuracy ratio measures
# how much predictive utility the synthetic data preserves.
# %%
# Check we have enough samples AND both classes in synthetic data
synth_classes = np.unique(y_synth)
has_both_classes = len(synth_classes) >= 2
print(f"Synthetic classes present: {synth_classes}, both classes: {has_both_classes}")
# %%
if len(X_synth) > 10 and has_both_classes:
# TRTR: Train Real, Test Real (baseline)
model_real = GradientBoostingClassifier(n_estimators=50, max_depth=3, random_state=42)
model_real.fit(X_train_real, y_train_real)
y_pred_trtr = model_real.predict(X_test)
y_prob_trtr = model_real.predict_proba(X_test)[:, 1]
# TSTR: Train Synthetic, Test Real
model_synth = GradientBoostingClassifier(n_estimators=50, max_depth=3, random_state=42)
model_synth.fit(X_synth, y_synth)
y_pred_tstr = model_synth.predict(X_test)
y_prob_tstr = model_synth.predict_proba(X_test)[:, 1]
# Metrics
print(f"\n{'Metric':<20} {'TRTR (Real)':<15} {'TSTR (Synth)':<15}")
print("-" * 50)
acc_trtr = accuracy_score(y_test, y_pred_trtr)
acc_tstr = accuracy_score(y_test, y_pred_tstr)
print(f"{'Accuracy':<20} {acc_trtr:<15.3f} {acc_tstr:<15.3f}")
try:
auc_trtr = roc_auc_score(y_test, y_prob_trtr)
auc_tstr = roc_auc_score(y_test, y_prob_tstr)
print(f"{'AUC-ROC':<20} {auc_trtr:<15.3f} {auc_tstr:<15.3f}")
utility_ratio = acc_tstr / acc_trtr
print(f"\nTSTR Ratio: {utility_ratio:.1%}")
if utility_ratio > 0.95:
print("GReaT synthetic data has HIGH utility for model training.")
elif utility_ratio > 0.85:
print("GReaT synthetic data has MODERATE utility for model training.")
else:
print("GReaT synthetic data has LIMITED utility - may need more training.")
except ValueError as e:
print(f"AUC calculation error: {e}")
else:
print("\nInsufficient valid synthetic samples for TSTR evaluation.")
if not has_both_classes:
print(f"Synthetic data has only {len(synth_classes)} class(es): {synth_classes}")
print("Need both classes (0 and 1) for classification - increase n_generate.")
else:
print("This can happen with very short training - increase epochs.")
# %% [markdown]
# ## 7. Statistical Tests
# %%
print("\n" + "=" * 70)
print("STATISTICAL FIDELITY TESTS")
print("=" * 70)
for col in numerical_cols:
if col in synthetic_df.columns:
real_vals = df[col].dropna().values
synth_vals = pd.to_numeric(synthetic_df[col], errors="coerce").dropna().values
if len(synth_vals) > 10:
# KS test
ks_stat, ks_pval = stats.ks_2samp(real_vals, synth_vals)
print(f"\n{col}:")
print(f" KS statistic: {ks_stat:.4f} (p-value: {ks_pval:.4f})")
# Mean difference
mean_diff = abs(real_vals.mean() - synth_vals.mean())
print(f" Mean difference: {mean_diff:.4f}")
# %% [markdown]
# **Interpretation**: The KS test measures the maximum distance between the real
# and synthetic cumulative distributions. Return features show high KS values
# (`ret_1d` 0.50, `ret_5d` 0.59, `ret_20d` 0.58), indicating that the LLM does
# not match the continuous return distributions well — synthetic returns are
# compressed toward zero with lower variance. Volatility (KS 0.32) and volume
# ratio (KS 0.10) are matched more closely. The categorical distributions also
# diverge: synthetic labels 94.8% of rows "down" while only 45.1% of real rows
# are "down" (real is 54.9% up / 45.1% down — synthetic inverts the balance),
# and under-generates "strong" momentum (9.8% vs 27.6%). The TSTR accuracy
# ratio (91.6%) and AUC drop (0.741 → 0.696) show that the downstream classifier
# trained on synthetic data is close to but not at parity with the real-trained
# baseline; the marginal-distribution failures above are the larger gap.
# %% [markdown]
# ## Key Takeaways
#
# 1. **Serialization is the key insight**: GReaT converts table rows to natural
# language sentences, letting a pre-trained LLM learn the joint distribution
# of mixed-type features without explicit distributional assumptions.
# 2. **Mixed-type handling is GReaT's comparative advantage**: Unlike GANs that
# require separate encoders for categorical columns, the LLM serialization
# approach treats numericals and categoricals uniformly as text tokens.
# 3. **TSTR utility depends on training budget**: this notebook fine-tunes
# distilgpt2 for 50 epochs on 1,400 ETF rows; the resulting TSTR accuracy
# ratio is reported in the evaluation cell above. Borisov et al. (2023)
# report higher TSTR ratios with larger backbones and longer fine-tuning;
# this notebook does not sweep epoch count or model size.
# 4. **Parsing failures are the main failure mode**: The autoregressive generator
# can produce tokens that break column parsing, especially with short
# fine-tuning. This is visible as NaN values in the generated output.
# 5. **Marginal fidelity is mixed**: The LLM captures scale features (volatility,
# volume) better than return distributions (KS 0.5+). TSTR evaluation is needed to
# verify that inter-feature dependencies transfer to downstream tasks.
#
# | Generator | Strength | Weakness |
# |-----------|----------|----------|
# | GReaT (LLM) | Mixed types, no assumptions | Slow, expensive |
# | TimeGAN | Temporal dynamics | Continuous only |
# | Tail-GAN | Tail risk focus | Complex setup |
# | Copula | Fast, simple | Distribution assumptions |
#
# **Next**: See [`07_dp_gan`](07_dp_gan.ipynb) for adding differential privacy guarantees to
# synthetic generation -- critical when training data contains sensitive records.
#
# **Book**: Section 5.6 discusses the serialization insight in depth, including
# how feature-name semantics from pre-training improve generation quality and
# how GReaT compares to GAN-based tabular generators (CTGAN, TVAE).
# %%
# Save synthetic data (consistent with other generators)
output_dir = get_output_dir(5, "great")
output_dir.mkdir(parents=True, exist_ok=True)
output_path = output_dir / "great_etf_features.parquet"
synthetic_df.to_parquet(output_path)
print(f"\nSaved GReaT synthetic data to {output_path}")
print("\nGReaT notebook complete!")