项目文件夹

文件
2026-07-13 13:26:28 +08:00

795 行
26 KiB
Python

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
"""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",
]