Skip to content
All library documents

Conditional Autoencoders for Characteristic-Based Asset Pricing

Code Machine Learning for Trading

Summary

This implementation describes a conditional autoencoder for asset pricing. A beta network maps each stock’s characteristics to latent factor loadings, while a linear factor network maps managed portfolio returns to factor returns. The model predicts a stock return by taking the sum of the paired loadings and factor values, allowing exposures to vary with firm characteristics while factors are derived from portfolio instruments.

The code also provides accessors for the learned loadings and factors, which can support inspection of the model’s components. Its L1 penalty applies only to weights in hidden layers of the beta network; it excludes biases and the final loading layer, and returns zero when there are no hidden layers. This is an architectural reference rather than an empirical study: it supplies no training procedure, dataset, predictive results, or comparison with other asset-pricing models.

Key ideas

  • The beta network turns stock characteristics into stock-specific latent factor loadings.
  • A linear network maps managed portfolio inputs into factor returns.
  • Predicted returns are formed from the sum of loading-factor products.
  • The regularizer encourages sparsity in hidden beta-network weights while leaving the output layer unpenalized.
  • The implementation defines model structure but provides no empirical evaluation.

Tags

Full text
# cae.py


```py
"""Conditional Autoencoder for Asset Pricing (GKX 2021).

Architecture:


- ConditionalAutoencoder: predicted return = dot(betas, factors)

Reference: Gu, Kelly, Xiu (2021) "Autoencoder Asset Pricing Models"
"""

from __future__ import annotations

import torch
import torch.nn as nn


class BetaNetwork(nn.Module):
    """Maps stock characteristics to per-stock factor loadings."""

    def __init__(self, n_characteristics: int, n_factors: int, hidden_units: tuple = (32,)):
        super().__init__()

        layers: list[nn.Module] = []
        in_features = n_characteristics

        for units in hidden_units:
            layers.append(nn.Linear(in_features, units))
            layers.append(nn.ReLU())
            in_features = units

        layers.append(nn.Linear(in_features, n_factors))
        self.network = nn.Sequential(*layers)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        return self.network(x)


class FactorNetwork(nn.Module):
    """Maps managed portfolio returns to factor returns (linear, no activation)."""

    def __init__(self, n_instruments: int, n_factors: int):
        super().__init__()
        self.linear = nn.Linear(n_instruments, n_factors, bias=False)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        return self.linear(x)


class ConditionalAutoencoder(nn.Module):
    """Conditional Autoencoder for Asset Pricing (GKX Architecture).

    Architecture:
    - Beta Network: characteristics -> ReLU -> factor loadings (with L1 regularization)
    - Factor Network: managed portfolios -> factor returns (linear)
    - Output: dot product of betas and factors

    Args:
        n_characteristics: Number of stock characteristics (L)
        n_instruments: Number of managed portfolio instruments (L+1 typically)
        n_factors: Number of latent factors (K)
        hidden_units: Tuple of hidden layer sizes for BetaNetwork
    """

    def __init__(
        self,
        n_characteristics: int,
        n_instruments: int,
        n_factors: int = 6,
        hidden_units: tuple = (32,),
    ):
        super().__init__()

        self.n_factors = n_factors
        self.n_characteristics = n_characteristics
        self.n_instruments = n_instruments

        self.beta_net = BetaNetwork(n_characteristics, n_factors, hidden_units)
        self.factor_net = FactorNetwork(n_instruments, n_factors)

    def forward(self, characteristics: torch.Tensor, portfolios: torch.Tensor) -> torch.Tensor:
        """Forward pass.

        Args:
            characteristics: (batch_size, n_characteristics) per-stock
            portfolios: (batch_size, n_instruments) managed portfolio features

        Returns:
            predicted_returns: (batch_size,)
        """
        betas = self.beta_net(characteristics)  # (batch, n_factors)
        factors = self.factor_net(portfolios)  # (batch, n_factors)
        pred = (betas * factors).sum(dim=1)
        return pred

    def get_betas(self, characteristics: torch.Tensor) -> torch.Tensor:
        """Extract factor loadings."""
        return self.beta_net(characteristics)

    def get_factors(self, portfolios: torch.Tensor) -> torch.Tensor:
        """Extract factor returns."""
        return self.factor_net(portfolios)


def l1_regularization(model: ConditionalAutoencoder, lambda_l1: float) -> torch.Tensor:
    """L1 regularization on BetaNetwork hidden Dense weights only.

    Matches the GKX (2021) reference, which applies ``kernel_regularizer='L1'``
    only to the *hidden* Dense layers. We exclude biases (sparsity is not
    meaningful) and the final output ``Linear(in, n_factors)`` layer (factor
    loadings should not be pushed to zero by an external regularizer — the
    autoencoder itself learns their magnitudes).
    """
    layers = [m for m in model.beta_net.network if isinstance(m, nn.Linear)]
    if len(layers) <= 1:
        # No hidden layers (CA0 — direct linear projection); no L1 to apply.
        return torch.zeros((), device=next(model.parameters()).device)
    hidden_layers = layers[:-1]  # exclude the final output Linear
    l1 = sum(layer.weight.abs().sum() for layer in hidden_layers)
    return lambda_l1 * l1

```

Shown in full with attribution under the source's licence. Licence: MIT

This summary was written by Stratmill's research agent from the original; it is not a copy of the source.