跳至正文
返回文库全部文档

用于条件贝塔和因子预测的工具变量 PCA

笔记本 《交易机器学习》

总结

本文档演示了工具变量 PCA,其中资产因子载荷被建模为收益发生前观测到的特征的线性函数。该方法使用交替最小二乘法估计特征到载荷的映射和实现因子,再通过旋转不变的子空间诊断评估载荷恢复效果。由于旋转和符号变化不会改变拟合收益,单个因子标签无法识别;比较时应关注恢复出的子空间。

合成面板将时间 t 的特征与时间 t+1 的收益配对,并设置训练片段、一个周期的禁入期以及后续评估片段。三阶段适配器拟合载荷和因子历史,仅使用每个决策时点可用的信息预测因子,再将预测映射至资产。示例还报告不确定性以及对假定因子数量的敏感性。关键注意事项是,准确重构并不意味着能择时捕捉因子:合成因子溢价彼此独立,因此预测阶段应与零收益基准比较。评估窗口是教学演示,并非最终的未触碰留出集或模型选择集。

核心观点

  • 工具变量 PCA 将因子载荷建模为滞后资产特征的线性函数。
  • 交替最小二乘法依次估计实现因子和特征到载荷的映射。
  • 旋转和符号变化使载荷子空间可识别,即使单个因子标签无法识别。
  • 滚动预测必须在每个评估因子实现值揭示之前完成。
  • 良好的结构恢复不保证因子溢价可预测,也不保证收益预测有用。

标签

全文
# Instrumented PCA: Conditional Betas and Factor Forecasts


# Instrumented PCA: Conditional Betas and Factor Forecasts

**Docker image**: `ml4t`

**Chapter 14: Latent Factor Models**

Instrumented PCA (IPCA) makes an asset's factor loadings linear functions of
characteristics observed before the return:

$$r^e_{i,t+1}=z_{i,t}^{\top}\Gamma f_{t+1}+\varepsilon_{i,t+1}.$$

This notebook uses a synthetic panel with known $\Gamma$ for two separate
checks. First, it verifies that alternating least squares (ALS) recovers the
loading *subspace*. Second, it passes the estimated factors through the
chapter's three-stage forecasting adapter without assuming that the factors
are predictable.

**Learning objectives**

- implement the two ALS updates for IPCA;
- evaluate recovery with rotation-invariant subspace diagnostics;
- map genuinely walk-forward factor forecasts back to asset returns; and
- report forecast uncertainty and sensitivity to the factor count.

**Evaluation contract**: the synthetic characteristics at $t$ generate only
$r_{t+1}$. The structural model uses 349 training pairs, followed by a
one-period embargo and 150 evaluation pairs. The evaluation window is a
teaching demonstration: it is neither a holdout kept untouched for a final
measurement nor a set used to choose between models.

**Prerequisite**: [`01_pca_equity_sectors`](01_pca_equity_sectors.ipynb)

**Book section**: Section 14.5, "Bridging economics and statistics with advanced models"

**Next**: [`05_rp_pca`](05_rp_pca.ipynb) changes the Stage 1 objective to
emphasize priced variation.

## 1. Setup

```python
"""Recover an IPCA loading subspace and evaluate walk-forward factor forecasts."""

from datetime import datetime
from time import perf_counter

import matplotlib.pyplot as plt
import numpy as np
import polars as pl
from matplotlib.colors import LinearSegmentedColormap
from matplotlib.patches import FancyBboxPatch
from ml4t.diagnostic.metrics import cross_sectional_ic_series
from ml4t.diagnostic.metrics.uncertainty import compute_ic_uncertainty
from scipy.linalg import orthogonal_procrustes

from utils.reproducibility import set_global_seeds
from utils.style import (
    COLORS,
    FIGSIZE,
    add_message_title,
    ml4t_diverging,
    ml4t_palette,
    show_with_alt,
    zero_line,
)
```

```python
N_PERIODS = 500
N_ASSETS = 100
N_CHARACTERISTICS = 10
N_TRUE_FACTORS = 3
N_IPCA_FACTORS = 3
TRAIN_BOUNDARY = 350
EMBARGO = 1
MAX_ITER = 100
N_BOOTSTRAP = 2_000
EWMA_HALF_LIFE = 12
SEED = 42

set_global_seeds(SEED)
```

The adapter keeps three responsibilities separate. IPCA estimates the
conditional loadings and realized factor history in Stage 1. Stage 2 uses
only factor realizations available at each decision. Stage 3 combines the
current characteristics with the fixed, train-only loading map.

```python
fig, ax = plt.subplots(figsize=FIGSIZE["single_wide"])
ax.set_xlim(0, 12)
ax.set_ylim(0, 4)
ax.axis("off")

boxes = [
    (0.4, "Stage 1", "Fit Γ and realized\nfactor history", COLORS["silver_muted"]),
    (4.4, "Stage 2", "Forecast next\nfactor premium", COLORS["amber_light"]),
    (8.4, "Stage 3", "Map through\ncurrent betas", COLORS["silver"]),
]
for x_pos, stage, detail, facecolor in boxes:
    patch = FancyBboxPatch(
        (x_pos, 0.9),
        3.2,
        1.7,
        boxstyle="round,pad=0.08",
        facecolor=facecolor,
        edgecolor=COLORS["blue"],
        linewidth=1.2,
    )
    ax.add_patch(patch)
    ax.text(x_pos + 1.6, 1.95, stage, ha="center", weight="semibold", color=COLORS["blue"])
    ax.text(x_pos + 1.6, 1.42, detail, ha="center", fontsize=8.5, color=COLORS["neutral"])
for x_pos in (3.75, 7.75):
    ax.annotate(
        "",
        xy=(x_pos + 0.55, 1.75),
        xytext=(x_pos, 1.75),
        arrowprops={"arrowstyle": "->", "color": COLORS["amber"], "lw": 1.5},
    )
add_message_title(ax, "The three stages of a conditional factor model")
show_with_alt(
    fig,
    "A schematic of three labelled boxes left to right joined by arrows. Stage 1 fits "
    "the loading matrix and the realized factor history; Stage 2 forecasts the next "
    "factor premium; Stage 3 maps that forecast back to assets through the current "
    "betas. The middle box is highlighted.",
)
```

## 2. Alternating least squares

For fixed $\Gamma$, each date is a small cross-sectional least-squares
problem for $f_{t+1}$. A small ridge term protects the solve when the
conditional beta matrix is nearly singular.

```python
def estimate_factor_history(
    returns: np.ndarray,
    characteristics: np.ndarray,
    gamma: np.ndarray,
) -> np.ndarray:
    """Estimate one realized factor vector per return cross-section."""
    n_periods = returns.shape[0]
    n_factors = gamma.shape[1]
    factors = np.empty((n_periods, n_factors))
    ridge = 1e-8 * np.eye(n_factors)
    for period in range(n_periods):
        betas = characteristics[period] @ gamma
        factors[period] = np.linalg.solve(
            betas.T @ betas + ridge,
            betas.T @ returns[period],
        )
    return factors
```

For fixed factors, stack the characteristic-factor interactions into one
pooled regression. The coefficient vector reshapes directly into $\Gamma$.

```python
def update_gamma(
    returns: np.ndarray,
    characteristics: np.ndarray,
    factors: np.ndarray,
) -> np.ndarray:
    """Update the characteristic loading map in one pooled regression."""
    n_characteristics = characteristics.shape[2]
    n_factors = factors.shape[1]
    gram = np.zeros((n_characteristics * n_factors,) * 2)
    score = np.zeros(n_characteristics * n_factors)
    for period, factor in enumerate(factors):
        design = np.einsum("nl,k->nlk", characteristics[period], factor).reshape(
            returns.shape[1], -1
        )
        gram += design.T @ design
        score += design.T @ returns[period]
    ridge = 1e-8 * np.eye(gram.shape[0])
    return np.linalg.solve(gram + ridge, score).reshape(n_characteristics, n_factors)
```

IPCA is unchanged by invertible rotations of $\Gamma$ and the factors.
Orthonormalizing the loading map and ordering directions by factor variance
chooses a stable representation without changing fitted returns.

```python
def normalize_ipca(
    gamma: np.ndarray,
    factors: np.ndarray,
) -> tuple[np.ndarray, np.ndarray]:
    """Choose an orthonormal, variance-ordered representation."""
    gamma_orthogonal, transform = np.linalg.qr(gamma)
    factor_history = factors @ transform.T
    covariance = np.atleast_2d(np.cov(factor_history, rowvar=False))
    eigenvalues, rotation = np.linalg.eigh(covariance)
    rotation = rotation[:, np.argsort(eigenvalues)[::-1]]
    gamma_normalized = gamma_orthogonal @ rotation
    factors_normalized = factor_history @ rotation
    anchors = np.argmax(np.abs(gamma_normalized), axis=0)
    signs = np.sign(gamma_normalized[anchors, np.arange(gamma.shape[1])])
    signs[signs == 0] = 1
    return gamma_normalized * signs, factors_normalized * signs
```

The initializer applies PCA to characteristic-managed returns. ALS then
alternates the two closed-form updates until both objects stabilize.

```python
def fit_ipca(
    returns: np.ndarray,
    characteristics: np.ndarray,
    n_factors: int,
    max_iter: int = 100,
    tolerance: float = 1e-6,
) -> dict[str, object]:
    """Fit IPCA by alternating least squares."""
    managed = np.einsum("tnl,tn->tl", characteristics, returns) / returns.shape[1]
    eigenvalues, eigenvectors = np.linalg.eigh(managed.T @ managed)
    gamma = eigenvectors[:, np.argsort(eigenvalues)[-n_factors:]]
    previous_factors = np.zeros((returns.shape[0], n_factors))
    converged = False
    for iteration in range(1, max_iter + 1):
        factors = estimate_factor_history(returns, characteristics, gamma)
        updated_gamma = update_gamma(returns, characteristics, factors)
        gamma_delta = np.max(np.abs(updated_gamma - gamma))
        factor_delta = np.max(np.abs(factors - previous_factors))
        gamma, previous_factors = updated_gamma, factors
        if max(gamma_delta, factor_delta) < tolerance:
            converged = True
            break
    factors = estimate_factor_history(returns, characteristics, gamma)
    gamma, factors = normalize_ipca(gamma, factors)
    fitted = np.einsum("tnk,tk->tn", characteristics @ gamma, factors)
    return {
        "gamma": gamma,
        "factors": factors,
        "converged": converged,
        "iterations": iteration,
        "mse": float(np.mean((returns - fitted) ** 2)),
    }
```

## 3. A timing-correct synthetic panel

Characteristics follow persistent AR(1) processes and are standardized
within each cross-section. The return paired with $z_t$ is generated from
the independent factor shock at $t+1$, exactly matching the model equation.

```python
def generate_ipca_panel(seed: int = SEED) -> dict[str, np.ndarray]:
    """Generate lagged characteristics and their next-period returns."""
    rng = np.random.default_rng(seed)
    characteristics = np.empty((N_PERIODS, N_ASSETS, N_CHARACTERISTICS))
    characteristics[0] = rng.normal(size=(N_ASSETS, N_CHARACTERISTICS))
    for period in range(1, N_PERIODS):
        innovation = rng.normal(size=(N_ASSETS, N_CHARACTERISTICS))
        characteristics[period] = 0.8 * characteristics[period - 1] + 0.6 * innovation
    means = characteristics.mean(axis=1, keepdims=True)
    scales = characteristics.std(axis=1, keepdims=True)
    characteristics = (characteristics - means) / scales
    true_gamma, _ = np.linalg.qr(rng.normal(size=(N_CHARACTERISTICS, N_TRUE_FACTORS)))
    factor_scales = np.array([0.040, 0.025, 0.015])
    true_factors = rng.normal(size=(N_PERIODS + 1, N_TRUE_FACTORS)) * factor_scales
    betas = characteristics @ true_gamma
    next_returns = np.einsum("tnk,tk->tn", betas, true_factors[1:])
    next_returns += rng.normal(scale=0.010, size=next_returns.shape)
    return {
        "characteristics": characteristics,
        "next_returns": next_returns,
        "true_gamma": true_gamma,
        "true_factors": true_factors,
    }
```

```python
panel = generate_ipca_panel()
train_stop = TRAIN_BOUNDARY - EMBARGO
test_start = TRAIN_BOUNDARY

train_characteristics = panel["characteristics"][:train_stop]
train_returns = panel["next_returns"][:train_stop]
embargo_characteristics = panel["characteristics"][train_stop:test_start]
embargo_returns = panel["next_returns"][train_stop:test_start]
test_characteristics = panel["characteristics"][test_start:]
test_returns = panel["next_returns"][test_start:]

print(
    f"Pairs: train={len(train_returns)}, embargo={len(embargo_returns)}, "
    f"evaluation={len(test_returns)}"
)
```

## 4. Stage 1: recover the loading subspace

Individual columns of $\Gamma$ are not identified: rotations and sign flips
leave fitted returns unchanged. Principal-angle cosines and the distance
between projection matrices therefore test the estimable object.

```python
started = perf_counter()
ipca = fit_ipca(
    train_returns,
    train_characteristics,
    n_factors=N_IPCA_FACTORS,
    max_iter=MAX_ITER,
)
elapsed = perf_counter() - started
print(
    f"ALS: converged={ipca['converged']}, iterations={ipca['iterations']}, "
    f"train MSE={ipca['mse']:.6f}, elapsed={elapsed:.2f}s"
)
```

```python
true_basis, _ = np.linalg.qr(panel["true_gamma"])
estimated_basis, _ = np.linalg.qr(ipca["gamma"])
principal_cosines = np.linalg.svd(true_basis.T @ estimated_basis, compute_uv=False)
projector_distance = np.linalg.norm(
    true_basis @ true_basis.T - estimated_basis @ estimated_basis.T,
    ord="fro",
)
alignment, _ = orthogonal_procrustes(estimated_basis, true_basis)
aligned_basis = estimated_basis @ alignment
alignment_rmse = float(np.sqrt(np.mean((true_basis - aligned_basis) ** 2)))

print(
    "Subspace: cosines="
    f"{np.round(principal_cosines, 4).tolist()}, "
    f"projector distance={projector_distance:.4f}, aligned RMSE={alignment_rmse:.4f}"
)
```

Both panels below use the same color scale. Similar vertical patterns after
orthogonal alignment indicate recovery of the loading subspace, not recovery
of arbitrarily labeled columns.

```python
gamma_cmap = LinearSegmentedColormap.from_list("ml4t_diverging", ml4t_diverging())
limit = float(np.max(np.abs(np.concatenate([true_basis, aligned_basis], axis=1))))
fig, axes = plt.subplots(1, 2, figsize=FIGSIZE["dual_h_tall"], sharey=True)
for ax, basis, label in zip(
    axes,
    (true_basis, aligned_basis),
    ("Known basis", "Estimated basis after alignment"),
    strict=True,
):
    image = ax.imshow(basis, aspect="auto", cmap=gamma_cmap, vmin=-limit, vmax=limit)
    ax.set_xlabel(f"{label}\nLatent direction")
    ax.set_xticks(range(N_TRUE_FACTORS), [f"F{k + 1}" for k in range(N_TRUE_FACTORS)])
axes[0].set_ylabel("Characteristic index")
fig.colorbar(image, ax=axes, label="Orthonormal loading coefficient", shrink=0.82)
add_message_title(
    axes[0],
    "Known and estimated loading matrices, side by side",
    subtitle="Both shown after aligning the estimate to the known basis",
)
show_with_alt(
    fig,
    "Two heatmaps side by side on one colour scale, characteristic index on the vertical "
    "axis and latent direction on the horizontal. The left is the known loading matrix "
    "the data was generated from; the right is the estimate after alignment. A shared "
    "colour bar gives the orthonormal loading coefficient.",
)
print(f"Minimum principal-angle cosine between the two subspaces: {principal_cosines.min():.3f}")
```

## 5. Stage 2: one-step factor forecasts

Each forecast is computed before the matching evaluation factor is appended
to history. The embargo-period return is observable at the first evaluation
decision, so it updates the factor history but never enters the Stage 1 fit.

```python
def constant_factor_forecast(history: np.ndarray) -> np.ndarray:
    """Forecast each factor with its expanding historical mean."""
    return history.mean(axis=0)
```

```python
def ar1_factor_forecast(history: np.ndarray) -> np.ndarray:
    """Fit one AR(1) per factor and forecast from the latest realization."""
    forecasts = np.empty(history.shape[1])
    design = np.column_stack([np.ones(len(history) - 1), history[:-1]])
    for factor in range(history.shape[1]):
        coefficients = np.linalg.lstsq(design[:, [0, factor + 1]], history[1:, factor], rcond=None)[
            0
        ]
        forecasts[factor] = coefficients[0] + coefficients[1] * history[-1, factor]
    return forecasts
```

```python
def ewma_factor_forecast(
    history: np.ndarray,
    half_life: int = EWMA_HALF_LIFE,
) -> np.ndarray:
    """Forecast with an exponentially weighted mean of available factors."""
    ages = np.arange(len(history) - 1, -1, -1)
    weights = np.exp(-np.log(2) * ages / half_life)
    weights /= weights.sum()
    return weights @ history
```

```python
def walk_forward_factor_forecasts(
    initial_history: np.ndarray,
    realized_factors: np.ndarray,
) -> dict[str, np.ndarray]:
    """Forecast first, then reveal and append each realized factor vector."""
    forecasters = {
        "Expanding mean": constant_factor_forecast,
        "AR(1)": ar1_factor_forecast,
        "EWMA": ewma_factor_forecast,
    }
    predictions = {name: np.empty_like(realized_factors) for name in forecasters}
    history = initial_history.copy()
    for step, realized in enumerate(realized_factors):
        for name, forecaster in forecasters.items():
            predictions[name][step] = forecaster(history)
        history = np.vstack([history, realized])
    return predictions
```

```python
embargo_factors = estimate_factor_history(
    embargo_returns,
    embargo_characteristics,
    ipca["gamma"],
)
initial_factor_history = np.vstack([ipca["factors"], embargo_factors])
realized_test_factors = estimate_factor_history(
    test_returns,
    test_characteristics,
    ipca["gamma"],
)
factor_forecasts = walk_forward_factor_forecasts(
    initial_factor_history,
    realized_test_factors,
)
```

## 6. Stage 3: asset forecasts and uncertainty

The mapper uses only current characteristics and the train-only $\Gamma$.
Forecast $R^2$ uses the economically neutral zero-return forecast as its
denominator. Cross-sectional IC uncertainty uses a Newey-West standard error;
the displayed interval is not an independence-based shortcut.

```python
def map_asset_forecasts(
    characteristics: np.ndarray,
    gamma: np.ndarray,
    factor_forecasts: np.ndarray,
) -> np.ndarray:
    """Map factor forecasts through current conditional betas."""
    return np.einsum("tnk,tk->tn", characteristics @ gamma, factor_forecasts)
```

```python
def as_long_panel(values: np.ndarray, value_name: str) -> pl.DataFrame:
    """Convert a period-by-asset matrix to the canonical long schema."""
    timestamps = pl.datetime_range(
        datetime(2000, 1, 1),
        datetime(2000, 1, 1) + pl.duration(days=values.shape[0] - 1),
        interval="1d",
        eager=True,
    )
    return pl.DataFrame(
        {
            "timestamp": np.repeat(timestamps.to_numpy(), values.shape[1]),
            "symbol": np.tile([f"A{i:03d}" for i in range(values.shape[1])], values.shape[0]),
            value_name: values.ravel(),
        }
    )
```

```python
def evaluate_asset_forecast(
    name: str,
    predictions: np.ndarray,
    realized_returns: np.ndarray,
    seed: int,
) -> dict[str, float | str]:
    """Compute zero-benchmark error and HAC IC uncertainty."""
    pred_frame = as_long_panel(predictions, "prediction")
    return_frame = as_long_panel(realized_returns, "forward_return")
    ic_frame = cross_sectional_ic_series(
        pred_frame,
        return_frame,
        date_col="timestamp",
        entity_col="symbol",
    )
    uncertainty = compute_ic_uncertainty(
        ic_frame,
        horizon=1,
        n_boot=N_BOOTSTRAP,
        seed=seed,
    )
    mse = float(np.mean((realized_returns - predictions) ** 2))
    zero_mse = float(np.mean(realized_returns**2))
    return {
        "name": name,
        "mse_ratio": mse / zero_mse,
        "r2_zero": 1 - mse / zero_mse,
        "mean_ic": uncertainty["mean_ic"],
        "ci_low": uncertainty["ci_hac_lower"],
        "ci_high": uncertainty["ci_hac_upper"],
        "p_hac": uncertainty["p_hac"],
    }
```

```python
forecast_results = []
for index, (name, forecast) in enumerate(factor_forecasts.items()):
    asset_predictions = map_asset_forecasts(
        test_characteristics,
        ipca["gamma"],
        forecast,
    )
    result = evaluate_asset_forecast(name, asset_predictions, test_returns, SEED + index)
    forecast_results.append(result)
    print(
        f"{name}: MSE ratio={result['mse_ratio']:.4f}, "
        f"IC={result['mean_ic']:.4f} "
        f"[{result['ci_low']:.4f}, {result['ci_high']:.4f}], "
        f"HAC p={result['p_hac']:.3f}"
    )
```

The zero-return benchmark is deliberately hard to beat when latent premia
are independent draws with mean zero. The IC intervals show whether any
apparent cross-sectional ordering is larger than the time-series uncertainty
around it.

```python
names = [result["name"] for result in forecast_results]
ratios = np.array([result["mse_ratio"] for result in forecast_results])
means = np.array([result["mean_ic"] for result in forecast_results])
lower = np.array([result["ci_low"] for result in forecast_results])
upper = np.array([result["ci_high"] for result in forecast_results])
colors = ml4t_palette(len(names), categorical=True)

fig, axes = plt.subplots(2, 1, figsize=FIGSIZE["dual_v"], sharex=True, constrained_layout=True)
axes[0].bar(names, ratios, color=colors)
zero_line(axes[0], at=1.0)
axes[0].set_ylabel("MSE ratio vs zero")
add_message_title(axes[0], "Test MSE relative to the zero-return forecast")

errors = np.vstack([means - lower, upper - means])
axes[1].errorbar(names, means, yerr=errors, fmt="o", color=COLORS["blue"], capsize=4)
zero_line(axes[1])
axes[1].set_ylabel("Mean rank IC")
axes[1].set_xlabel("Walk-forward Stage 2 forecaster")
add_message_title(axes[1], "Mean rank IC with its HAC interval")
show_with_alt(
    fig,
    "Two stacked panels sharing a horizontal axis of Stage 2 forecaster. The upper is a "
    "bar chart of each forecaster's test MSE as a ratio to the zero-return forecast, "
    "with a dashed line at one. The lower plots each forecaster's mean rank IC as a "
    "point with a HAC interval, against a dashed line at zero.",
)
```

## 7. Factor-count sensitivity

This is a sensitivity analysis, not hyperparameter selection. Every value of
$K$ is fit on the same training panel and evaluated with the expanding-mean
forecaster. The evaluation window never chooses the reported factor count.

```python
def evaluate_factor_count(n_factors: int) -> dict[str, float]:
    """Fit one K and evaluate its expanding-mean forecast."""
    candidate = fit_ipca(
        train_returns,
        train_characteristics,
        n_factors=n_factors,
        max_iter=MAX_ITER,
    )
    embargo_history = estimate_factor_history(
        embargo_returns, embargo_characteristics, candidate["gamma"]
    )
    initial_history = np.vstack([candidate["factors"], embargo_history])
    realized = estimate_factor_history(test_returns, test_characteristics, candidate["gamma"])
    forecasts = walk_forward_factor_forecasts(initial_history, realized)["Expanding mean"]
    predictions = map_asset_forecasts(test_characteristics, candidate["gamma"], forecasts)
    result = evaluate_asset_forecast(
        f"K={n_factors}", predictions, test_returns, SEED + 100 + n_factors
    )
    return {
        "k": float(n_factors),
        "train_mse": float(candidate["mse"]),
        "mse_ratio": float(result["mse_ratio"]),
        "mean_ic": float(result["mean_ic"]),
        "ci_low": float(result["ci_low"]),
        "ci_high": float(result["ci_high"]),
    }
```

```python
k_results = [evaluate_factor_count(n_factors) for n_factors in range(1, 7)]
for result in k_results:
    print(
        f"K={int(result['k'])}: train MSE={result['train_mse']:.6f}, "
        f"evaluation MSE ratio={result['mse_ratio']:.4f}, "
        f"IC={result['mean_ic']:.4f} "
        f"[{result['ci_low']:.4f}, {result['ci_high']:.4f}]"
    )
```

```python
k_values = np.array([result["k"] for result in k_results], dtype=int)
train_mse = np.array([result["train_mse"] for result in k_results])
k_ratios = np.array([result["mse_ratio"] for result in k_results])
k_ic = np.array([result["mean_ic"] for result in k_results])
k_low = np.array([result["ci_low"] for result in k_results])
k_high = np.array([result["ci_high"] for result in k_results])

fig, axes = plt.subplots(3, 1, figsize=FIGSIZE["grid_3x2"], sharex=True, constrained_layout=True)
axes[0].plot(k_values, train_mse, marker="o", color=COLORS["blue"])
axes[0].set_ylabel("Training MSE")
add_message_title(axes[0], "Training MSE against assumed factor count")
axes[1].plot(k_values, k_ratios, marker="o", color=COLORS["amber"])
zero_line(axes[1], at=1.0)
axes[1].set_ylabel("MSE ratio vs zero")
add_message_title(axes[1], "Test MSE relative to zero, against assumed factor count")
axes[2].errorbar(
    k_values,
    k_ic,
    yerr=np.vstack([k_ic - k_low, k_high - k_ic]),
    fmt="o-",
    color=COLORS["copper"],
    capsize=3,
)
zero_line(axes[2])
axes[2].set_ylabel("Mean rank IC")
axes[2].set_xlabel("Assumed factor count K")
add_message_title(axes[2], "Mean rank IC with its HAC interval, by factor count")
show_with_alt(
    fig,
    "Three stacked panels sharing a horizontal axis of assumed factor count. The top "
    "plots training MSE, the middle test MSE as a ratio to the zero-return forecast "
    "against a dashed line at one, and the bottom mean rank IC as points with HAC "
    "intervals against a dashed line at zero.",
)
```

## 8. Takeaways

1. **Timing defines the model.** Characteristics at $t$ are paired with the
   return and factor realization at $t+1$; shifting a contemporaneously
   generated return would test a different data-generating process.
2. **The loading subspace is identified, not its labels.** Principal angles
   and projection distance remain valid under rotations and sign changes.
3. **Walk-forward means forecast, then reveal.** Each evaluation factor is
   appended only after its prediction, while the one-period embargo protects
   the structural fit at the boundary.
4. **Good reconstruction is not factor timing.** ALS recovers the synthetic
   structure, but all Stage 2 forecasters remain indistinguishable from the
   zero-return baseline when factor premia are independent.
5. **Sensitivity is not selection.** The $K$ sweep documents how the result
   changes across plausible dimensions; it does not tune on the evaluation
   window.
![notebook output](figures/p1_1.png)
![notebook output](figures/p1_2.png)
![notebook output](figures/p1_3.png)
![notebook output](figures/p1_4.png)

在遵守原作品许可的前提下,附作者信息全文展示。 许可协议: MIT

此摘要由 Stratmill 研究智能体根据原文撰写,并非原文副本。