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

795 lines
26 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""ML4T Visualization Style.
Canonical color palette, matplotlib rcParams, Plotly template, and chart
helpers for all book visualizations.
## Automatic Styling (Matplotlib)
ML4T style is applied automatically when running from repo root.
The ``matplotlibrc`` file in the repo root is loaded by matplotlib
before any other config. No imports or function calls needed.
## Explicit Color References
from utils.style import COLORS
ax.plot(x, y, color=COLORS['blue'])
ax.axhline(0, color=COLORS['amber'], linestyle='--')
## Plotly
import plotly.io as pio
pio.templates.default = "ml4t" # Auto-registered on import
"""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, Literal
import matplotlib.pyplot as plt
import numpy as np
if TYPE_CHECKING:
from matplotlib.axes import Axes
# =============================================================================
# ML4T COLOR PALETTE
# =============================================================================
# Aligned with ml4t.io website identity
COLORS = {
# Primary blues (core identity)
"blue": "#0a1628", # Deep blue - primary emphasis, main data
"blue_light": "#152238", # Lighter blue - secondary elements
"slate": "#1a2d4a", # Mid-blue - tertiary, gridlines
# Silver tones (backgrounds, text)
"silver": "#F8F8F6", # Light silver - text on dark, highlights
"silver_muted": "#e8e8e6", # Muted silver - borders, subtle elements
# Warm accents (highlights, emphasis)
"amber": "#D4A84B", # Warm amber - CTAs, important highlights
"amber_light": "#E4B85B", # Lighter amber - hover states
"copper": "#C87533", # Copper - secondary accent
# Semantic (for data meaning)
"positive": "#10b981", # Success green - profits, gains
"negative": "#ef4444", # Error red - losses (use sparingly!)
"neutral": "#334155", # Slate gray - neutral elements
# Backgrounds
"bg_light": "#FAFAF9", # Warm off-white (light mode)
"bg_dark": "#0a1628", # Deep blue (dark mode)
}
# Grayscale equivalents for print
GRAYSCALE = {
"blue": 0.10, # ~10% gray (very dark)
"slate": 0.25, # ~25% gray
"amber": 0.65, # ~65% gray
"silver": 0.97, # ~97% gray (nearly white)
}
# =============================================================================
# MATPLOTLIB STYLE CONFIGURATIONS
# =============================================================================
_BASE_STYLE = {
# Figure
"figure.dpi": 100,
"figure.figsize": (10, 6),
"savefig.dpi": 150,
"savefig.bbox": "tight",
"savefig.pad_inches": 0.1,
# Axes
"axes.spines.top": False,
"axes.spines.right": False,
"axes.titlesize": 14,
"axes.titleweight": "semibold",
"axes.titlepad": 12,
"axes.labelsize": 11,
"axes.labelpad": 8,
# Grid
"axes.grid": True,
"grid.alpha": 0.4,
"grid.linewidth": 0.5,
# Ticks
"xtick.labelsize": 10,
"ytick.labelsize": 10,
"xtick.major.pad": 4,
"ytick.major.pad": 4,
# Lines
"lines.linewidth": 2,
"lines.markersize": 6,
# Legend
"legend.frameon": False,
"legend.fontsize": 10,
# Font (prefer DM Sans, fallback to system sans)
"font.family": ["sans-serif"],
"font.sans-serif": ["DM Sans", "DejaVu Sans", "Helvetica", "Arial"],
"font.size": 10,
}
ML4T_LIGHT_STYLE = {
**_BASE_STYLE,
"figure.facecolor": COLORS["bg_light"],
"axes.facecolor": "white",
"axes.edgecolor": COLORS["silver_muted"],
"axes.labelcolor": COLORS["neutral"],
"axes.titlecolor": COLORS["blue"],
"xtick.color": COLORS["neutral"],
"ytick.color": COLORS["neutral"],
"grid.color": COLORS["silver_muted"],
"text.color": COLORS["neutral"],
}
ML4T_DARK_STYLE = {
**_BASE_STYLE,
"figure.facecolor": COLORS["bg_dark"],
"axes.facecolor": COLORS["blue_light"],
"axes.edgecolor": COLORS["slate"],
"axes.labelcolor": COLORS["silver"],
"axes.titlecolor": COLORS["silver"],
"xtick.color": COLORS["silver_muted"],
"ytick.color": COLORS["silver_muted"],
"grid.color": COLORS["slate"],
"text.color": COLORS["silver"],
}
# =============================================================================
# STYLE APPLICATION
# =============================================================================
def apply_ml4t_style(mode: Literal["light", "dark"] = "light") -> None:
"""Apply ML4T style to both Matplotlib and Plotly.
Args:
mode: 'light' (default) for white backgrounds, 'dark' for blue backgrounds
"""
if mode == "light":
plt.rcParams.update(ML4T_LIGHT_STYLE)
else:
plt.rcParams.update(ML4T_DARK_STYLE)
# Apply Plotly template if available
import contextlib
with contextlib.suppress(ImportError):
_register_plotly_template()
# =============================================================================
# PALETTE HELPERS
# =============================================================================
def ml4t_palette(n: int = 5, categorical: bool = False) -> list[str]:
"""Return colors from the ML4T palette.
Args:
n: Number of colors to return (max 5)
categorical: If True, returns distinct colors for categories.
If False, returns blue gradient for sequential data.
Returns:
List of hex color strings
"""
if categorical:
colors = [
COLORS["blue"],
COLORS["amber"],
COLORS["slate"],
COLORS["copper"],
COLORS["silver_muted"],
]
else:
colors = [
COLORS["blue"],
COLORS["slate"],
COLORS["blue_light"],
COLORS["silver_muted"],
COLORS["silver"],
]
return colors[:n]
def ml4t_diverging() -> list[str]:
"""Return diverging palette (negative to positive).
Use for data with meaningful zero point (e.g., returns, correlations).
Returns:
List of 3 colors: [negative, neutral, positive]
"""
return [COLORS["negative"], COLORS["silver_muted"], COLORS["positive"]]
# =============================================================================
# CHART HELPERS
# =============================================================================
def annotate_peak(ax: Axes, x: object, y: object, label: str, offset: tuple = (10, 10)) -> None:
"""Annotate a peak/trough with ML4T styling.
Args:
ax: matplotlib axes
x, y: Coordinates of the point
label: Text label
offset: (x, y) offset in points
"""
ax.annotate(
label,
xy=(x, y),
xytext=offset,
textcoords="offset points",
fontsize=9,
color=COLORS["neutral"],
arrowprops={
"arrowstyle": "->",
"color": COLORS["amber"],
"connectionstyle": "arc3,rad=0.2",
},
bbox={
"boxstyle": "round,pad=0.3",
"facecolor": COLORS["silver"],
"edgecolor": COLORS["silver_muted"],
},
)
def add_regime_shading(ax: Axes, periods: list[tuple], label: str = "Crisis") -> None:
"""Add regime shading to a time series plot.
Args:
ax: matplotlib axes
periods: List of (start, end) tuples defining regime periods
label: Label for legend
"""
for i, (start, end) in enumerate(periods):
ax.axvspan(
start,
end,
alpha=0.15,
color=COLORS["amber"],
label=label if i == 0 else None,
)
def format_pct_axis(ax: Axes, axis: Literal["x", "y", "both"] = "y") -> None:
"""Format axis as percentage with ML4T styling.
Args:
ax: matplotlib axes
axis: Which axis to format ('x', 'y', or 'both')
"""
from matplotlib.ticker import PercentFormatter
formatter = PercentFormatter(xmax=1, decimals=0)
if axis in ("y", "both"):
ax.yaxis.set_major_formatter(formatter)
if axis in ("x", "both"):
ax.xaxis.set_major_formatter(formatter)
# =============================================================================
# PLOTLY TEMPLATE (optional — only used if Plotly is installed)
# =============================================================================
def _register_plotly_template() -> None:
"""Register the ML4T template with Plotly."""
import plotly.graph_objects as go
import plotly.io as pio
template = go.layout.Template(
layout=go.Layout(
font=dict(
family="DM Sans, DejaVu Sans, sans-serif",
size=11,
color=COLORS["neutral"],
),
paper_bgcolor=COLORS["bg_light"],
plot_bgcolor="white",
title=dict(
font=dict(size=14, color=COLORS["blue"]),
x=0.5,
xanchor="center",
),
xaxis=dict(
gridcolor=COLORS["silver_muted"],
linecolor=COLORS["silver_muted"],
tickfont=dict(size=10),
title=dict(font=dict(size=11)),
showgrid=True,
gridwidth=0.5,
),
yaxis=dict(
gridcolor=COLORS["silver_muted"],
linecolor=COLORS["silver_muted"],
tickfont=dict(size=10),
title=dict(font=dict(size=11)),
showgrid=True,
gridwidth=0.5,
),
colorway=[
COLORS["blue"],
COLORS["amber"],
COLORS["slate"],
COLORS["copper"],
COLORS["positive"],
COLORS["negative"],
],
legend=dict(
bgcolor="rgba(255,255,255,0.8)",
bordercolor=COLORS["silver_muted"],
borderwidth=1,
font=dict(size=10),
),
hoverlabel=dict(
bgcolor="white",
font_size=11,
font_family="DM Sans, DejaVu Sans, sans-serif",
),
)
)
pio.templates["ml4t"] = template
# Auto-register Plotly template on import
_register_plotly_template()
HAS_PLOTLY = True
# =============================================================================
# PUBLICATION (BOOK) STYLE — MIT Press, dual-track (grayscale print + color web)
# =============================================================================
# Used by `~/ml4t/book/<ch>/figures/scripts/generate_figure_*.py`.
# Notebooks may also call `apply_book_style()` to render the same look.
#
# Two tracks, same data, same script:
# - "print": grayscale-first, semantic fills, varied linestyles. Top-level PNG.
# - "color": ML4T palette overlay. `color/` subdir.
# The grayscale track is the source of truth: data must be legible without color.
# Semantic grayscale fills — vocabulary mirrors `visualization-style/SKILL.md`.
# Use these by ROLE, not by hex. The print track resolves them to grays;
# the color track resolves them to the ML4T palette.
GRAY_FILLS = {
"primary": "#000000", # titles, lead data series, key emphasis
"secondary": "#808080", # second series — widened from #404040 for print contrast
"tertiary": "#c8c8c8", # third series, supporting elements
"quaternary": "#e8e8e8", # fourth series only — keep grayscale separable
"muted": "#a8a8a8", # de-emphasized, comparison baselines
"border": "#666666", # connectors, axis lines (data side)
"highlight": "#d9d9d9", # ~85% white — emphasis band fill
"container": "#f2f2f2", # ~95% white — phase container fill
"foundation": "#b3b3b3", # ~70% white — foundation layer fill
"canvas": "#ffffff", # page background
}
COLOR_FILLS = {
"primary": COLORS["blue"], # #0a1628 navy — primary series
"secondary": COLORS["amber"], # #D4A84B amber — secondary series
"tertiary": COLORS["copper"], # #C87533 copper — tertiary (kept distinct from navy)
"quaternary": COLORS["slate"], # #1a2d4a mid-blue — fourth series only
"muted": COLORS["silver_muted"], # #e8e8e6
"border": COLORS["neutral"], # #334155
"highlight": COLORS["amber_light"],
"container": COLORS["bg_light"],
"foundation": COLORS["silver"],
"canvas": "#ffffff",
}
# Categorical cyclers for `axes.prop_cycle`. The print track pairs GRAY_CYCLER
# with LINESTYLE_CYCLER so a B&W readout stays legible; the color track relies
# on hue alone (no linestyle pairing — see apply_book_style). Color order
# prioritizes perceptual separation for the first 4 entries (most figures use
# ≤4 series); slate is positioned last because it reads as a second navy next
# to blue. GRAY_CYCLER mirrors the GRAY_FILLS weight order (secondary widened
# to #808080 for print contrast) while keeping every entry dark enough to read
# as a line on white.
COLOR_CYCLER = [
COLORS["blue"], # navy — primary
COLORS["amber"], # gold — secondary
COLORS["copper"], # orange — tertiary
COLORS["positive"], # green — fourth
COLORS["negative"], # red — fifth (semantic, use sparingly)
COLORS["slate"], # navy — sixth (only when ≥6 series; reads close to blue)
]
GRAY_CYCLER = ["#000000", "#808080", "#404040", "#a8a8a8", "#666666", "#c8c8c8"]
LINESTYLE_CYCLER = ["-", "--", ":", "-.", "-", "--"]
MARKER_CYCLER = ["o", "s", "^", "D", "v", "P"]
# =============================================================================
# CANONICAL FIGURE SIZES (Packt embed width = 5.833")
# =============================================================================
# Width is fixed at 5.833" — the typeset width Packt uses in the manuscript
# template. Heights are picked per layout so panels render at proportions
# that don't dominate page vertical space. Use these in generate scripts;
# do NOT introduce ad-hoc figsize tuples per figure.
PAGE_WIDTH = 5.833 # Packt typeset embed width in inches
FIGSIZE = {
"single_wide": (PAGE_WIDTH, 2.6), # short time series, comparisons
"single": (PAGE_WIDTH, 3.4), # default single panel (~1.7:1)
"single_tall": (PAGE_WIDTH, 4.0), # detail-heavy single panel
"dual_h": (PAGE_WIDTH, 2.6), # two side-by-side panels
"dual_h_tall": (PAGE_WIDTH, 3.2), # two side-by-side, taller panels
"dual_v": (PAGE_WIDTH, 5.0), # two stacked panels
"triple_h": (PAGE_WIDTH, 2.2), # three side-by-side panels, short
"triple_h_tall": (PAGE_WIDTH, 3.0), # three side-by-side, detail
"grid_2x2": (PAGE_WIDTH, 4.0), # 2 rows × 2 cols, simple axes
"grid_2x3": (PAGE_WIDTH, 3.5), # 2 rows × 3 cols
"grid_3x2": (PAGE_WIDTH, 5.5), # 3 rows × 2 cols (square-ish grid)
"dashboard_2x2": (PAGE_WIDTH, 5.5), # 2×2 with date axes / rotated labels
"dashboard_2x3": (PAGE_WIDTH, 4.5), # 2×3 with date axes / rotated labels
}
_BOOK_BASE_STYLE = {
# Kept in sync with matplotlibrc at repo root. The auto-applied
# matplotlibrc covers all default runs; this dict is the explicit-apply
# override for book-figure scripts that swap between print and color
# tracks via ``apply_book_style()``.
"figure.dpi": 100,
"figure.figsize": FIGSIZE["single"],
"figure.facecolor": COLORS["bg_light"],
"figure.constrained_layout.use": True,
"savefig.dpi": 300,
"savefig.bbox": "tight",
"savefig.pad_inches": 0.05,
"savefig.facecolor": COLORS["bg_light"],
"axes.facecolor": COLORS["bg_light"],
"axes.spines.top": False,
"axes.spines.right": False,
"axes.titlesize": 10,
"axes.titleweight": "normal",
"axes.titlelocation": "left",
"axes.titlepad": 6,
"axes.labelsize": 9,
"axes.labelpad": 4,
"axes.linewidth": 0.75,
"axes.grid": False,
"axes.axisbelow": True,
"grid.linewidth": 0.5,
"grid.alpha": 0.6,
"grid.linestyle": "--",
"xtick.labelsize": 8,
"ytick.labelsize": 8,
"xtick.major.size": 3,
"ytick.major.size": 3,
"xtick.major.width": 0.6,
"ytick.major.width": 0.6,
"xtick.direction": "out",
"ytick.direction": "out",
"lines.linewidth": 1.4,
"lines.markersize": 4,
"lines.markeredgewidth": 0,
"legend.frameon": False,
"legend.fontsize": 8,
"legend.handlelength": 2.0,
"font.family": ["sans-serif"],
"font.sans-serif": ["Source Sans 3", "DejaVu Sans", "Helvetica", "Arial"],
"font.size": 9,
"image.cmap": "cividis",
}
def _cycler(colors: list[str], linestyles: list[str] | None = None):
"""Build a prop_cycle from colors + optional linestyles. Local import keeps
the module top-level cheap."""
from cycler import cycler as cy
cyc = cy(color=colors)
if linestyles is not None:
cyc = cyc + cy(linestyle=linestyles[: len(colors)])
return cyc
BOOK_PRINT_STYLE = {
# PRINT track is for the printed book — on white paper, so revert
# the warm-cream backgrounds back to plain white.
**_BOOK_BASE_STYLE,
"figure.facecolor": "white",
"savefig.facecolor": "white",
"axes.facecolor": "white",
"axes.edgecolor": "#333333",
"axes.labelcolor": "#000000",
"axes.titlecolor": "#000000",
"xtick.color": "#000000",
"ytick.color": "#000000",
"grid.color": "#cccccc",
"text.color": "#000000",
}
BOOK_COLOR_STYLE = {
# COLOR track is for web/README/Google Drive — matches the website's
# warm-cream bg_light surface.
**_BOOK_BASE_STYLE,
"axes.edgecolor": COLORS["neutral"],
"axes.labelcolor": COLORS["neutral"],
"axes.titlecolor": COLORS["neutral"],
"xtick.color": COLORS["neutral"],
"ytick.color": COLORS["neutral"],
"grid.color": COLORS["silver_muted"],
"text.color": COLORS["neutral"],
}
def apply_book_style(mode: Literal["print", "color"] = "print") -> None:
"""Set rcParams for a book-figure generation script.
Call once at script start (or before each render in a dual-track loop).
Resolves the prop_cycle to grayscale (with linestyle variation) for
``print`` and to the ML4T color palette for ``color``.
"""
style = BOOK_PRINT_STYLE if mode == "print" else BOOK_COLOR_STYLE
plt.rcParams.update(style)
if mode == "print":
plt.rcParams["axes.prop_cycle"] = _cycler(GRAY_CYCLER, LINESTYLE_CYCLER)
else:
plt.rcParams["axes.prop_cycle"] = _cycler(COLOR_CYCLER)
def save_dual(
make_fig,
output_basename: str,
output_dir: str | Path,
dpi: int = 300,
) -> tuple[Path, Path]:
"""Render and save both tracks of a publication figure.
``make_fig(palette, mode)`` is called twice — once with ``GRAY_FILLS`` /
``"print"`` and once with ``COLOR_FILLS`` / ``"color"``. The print PNG
lands at ``output_dir/{basename}.png`` (top-level grayscale default).
The color PNG lands at ``output_dir/color/{basename}_color.png``.
Args:
make_fig: Callable ``(palette: dict, mode: str) -> matplotlib.Figure``.
Must build the figure from scratch each call — the caller closes it.
output_basename: e.g. ``"figure_2_2_survivorship_bias"``. No extension.
output_dir: chapter ``figures/`` directory.
dpi: PNG resolution (default 300).
Returns:
(print_path, color_path) — both absolute.
"""
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
color_dir = output_dir / "color"
color_dir.mkdir(parents=True, exist_ok=True)
# Print track first — that's the canonical artifact.
apply_book_style("print")
fig = make_fig(GRAY_FILLS, "print")
print_path = output_dir / f"{output_basename}.png"
fig.savefig(print_path, dpi=dpi, bbox_inches="tight", facecolor="white")
plt.close(fig)
# Color track.
apply_book_style("color")
fig = make_fig(COLOR_FILLS, "color")
color_path = color_dir / f"{output_basename}_color.png"
fig.savefig(color_path, dpi=dpi, bbox_inches="tight", facecolor="white")
plt.close(fig)
return print_path, color_path
# =============================================================================
# BOOK-SPECIFIC FUNCTIONS (LEGACY)
# =============================================================================
# DEPRECATED: Style is now applied automatically via matplotlibrc in repo root.
ML4T_STYLE = Path(__file__).parent.parent / "matplotlibrc"
def save_figure(
fig,
name: str,
chapter: str | None = None,
formats: list[str] | None = None,
dpi: int = 150,
) -> None:
"""Save figure with ML4T conventions.
Args:
fig: matplotlib figure object
name: Base filename (without extension)
chapter: Optional chapter directory (e.g., "06_alpha_factor_engineering")
formats: List of formats to save (default: ['png', 'pdf'])
dpi: Resolution for raster formats (default: 150)
"""
formats = formats or ["png", "pdf"]
if chapter:
repo_root = Path(__file__).parent.parent
output_dir = repo_root / chapter / "visualizations"
else:
output_dir = Path(".")
output_dir.mkdir(parents=True, exist_ok=True)
for fmt in formats:
output_path = output_dir / f"{name}.{fmt}"
fig.savefig(output_path, format=fmt, dpi=dpi, bbox_inches="tight")
print(f"Saved: {output_path}")
def plot_fidelity_comparison(
real_data: np.ndarray,
synthetic_data: np.ndarray,
title: str = "Real vs Synthetic Distribution",
n_samples: int = 1000,
figsize: tuple = (12, 5),
flatten_method: str = "mean",
random_state: int = 42,
) -> plt.Figure:
"""Create standardized fidelity comparison plot using PCA and t-SNE.
Designed for grayscale compatibility:
- Real data: dark circles (filled)
- Synthetic data: amber X markers (open)
Args:
real_data: Real sequences. Shape can be:
- (n_samples, seq_len, n_features): 3D time series
- (n_samples, n_features): 2D tabular
synthetic_data: Synthetic sequences, same shape as real_data
title: Plot title
n_samples: Number of samples to visualize (subsampled if larger)
figsize: Figure size (width, height)
flatten_method: How to flatten 3D data to 2D:
- "mean": Average across time dimension (default)
- "last": Use last timestep only
- "flatten": Concatenate all timesteps (high-dim)
random_state: Random seed for reproducibility
Returns:
matplotlib Figure object
"""
from sklearn.decomposition import PCA
from sklearn.manifold import TSNE
np.random.seed(random_state)
# Handle 3D (time series) vs 2D (tabular) data
if real_data.ndim == 3:
if flatten_method == "mean":
real_flat = real_data.mean(axis=1)
synth_flat = synthetic_data.mean(axis=1)
elif flatten_method == "last":
real_flat = real_data[:, -1, :]
synth_flat = synthetic_data[:, -1, :]
elif flatten_method == "flatten":
real_flat = real_data.reshape(real_data.shape[0], -1)
synth_flat = synthetic_data.reshape(synthetic_data.shape[0], -1)
else:
raise ValueError(f"Unknown flatten_method: {flatten_method}")
else:
real_flat = real_data
synth_flat = synthetic_data
# Subsample for visualization
n_viz = min(n_samples, len(real_flat), len(synth_flat))
idx_real = np.random.choice(len(real_flat), n_viz, replace=False)
idx_synth = np.random.choice(len(synth_flat), n_viz, replace=False)
real_sample = real_flat[idx_real]
synth_sample = synth_flat[idx_synth]
# PCA - fit on real, transform both
n_features = real_sample.shape[1] if real_sample.ndim > 1 else 1
n_pca = min(2, n_features, n_viz)
pca = PCA(n_components=n_pca)
pca.fit(real_sample)
real_pca = pca.transform(real_sample)
synth_pca = pca.transform(synth_sample)
# t-SNE - fit jointly for proper comparison
combined = np.vstack([real_sample, synth_sample])
perplexity = min(40, max(2, n_viz // 4))
n_tsne = min(2, n_features)
tsne = TSNE(
n_components=n_tsne, perplexity=perplexity, max_iter=1000, random_state=random_state
)
combined_tsne = tsne.fit_transform(combined)
real_tsne = combined_tsne[:n_viz]
synth_tsne = combined_tsne[n_viz:]
# Create figure with aligned axes
fig, axes = plt.subplots(1, 2, figsize=figsize)
# Style constants for grayscale compatibility
real_color = COLORS["blue"]
synth_color = COLORS["amber"]
marker_size = 25
alpha = 0.6
# PCA plot (handle 1D case when n_features < 2)
pca_y_real = real_pca[:, 1] if n_pca >= 2 else np.zeros(len(real_pca))
pca_y_synth = synth_pca[:, 1] if n_pca >= 2 else np.zeros(len(synth_pca))
axes[0].scatter(
real_pca[:, 0],
pca_y_real,
c=real_color,
marker="o",
s=marker_size,
alpha=alpha,
label="Real",
edgecolors="none",
)
axes[0].scatter(
synth_pca[:, 0],
pca_y_synth,
c=synth_color,
marker="x",
s=marker_size,
alpha=alpha,
label="Synthetic",
linewidths=1.5,
)
axes[0].set_xlabel("PC1")
axes[0].set_ylabel("PC2" if n_pca >= 2 else "")
axes[0].set_title("PCA Projection")
axes[0].legend(loc="upper right", framealpha=0.9)
# t-SNE plot (handle 1D case when n_features < 2)
tsne_y_real = real_tsne[:, 1] if n_tsne >= 2 else np.zeros(len(real_tsne))
tsne_y_synth = synth_tsne[:, 1] if n_tsne >= 2 else np.zeros(len(synth_tsne))
axes[1].scatter(
real_tsne[:, 0],
tsne_y_real,
c=real_color,
marker="o",
s=marker_size,
alpha=alpha,
label="Real",
edgecolors="none",
)
axes[1].scatter(
synth_tsne[:, 0],
tsne_y_synth,
c=synth_color,
marker="x",
s=marker_size,
alpha=alpha,
label="Synthetic",
linewidths=1.5,
)
axes[1].set_xlabel("t-SNE 1")
axes[1].set_ylabel("t-SNE 2" if n_tsne >= 2 else "")
axes[1].set_title("t-SNE Projection")
axes[1].legend(loc="upper right", framealpha=0.9)
fig.suptitle(title, fontsize=14, fontweight="semibold", y=1.02)
plt.tight_layout()
return fig
# =============================================================================
# MODULE EXPORTS
# =============================================================================
__all__ = [
# Palette
"COLORS",
"GRAYSCALE",
# Matplotlib styles
"ML4T_LIGHT_STYLE",
"ML4T_DARK_STYLE",
# Style application
"apply_ml4t_style",
# Palette helpers
"ml4t_palette",
"ml4t_diverging",
# Chart helpers
"annotate_peak",
"add_regime_shading",
"format_pct_axis",
# Book-specific
"ML4T_STYLE",
"HAS_PLOTLY",
"save_figure",
"plot_fidelity_comparison",
]