Codex CLI で論文の再現実験をやってみる
日々たくさんの論文が出てきて、実際に手元で検証したいけどリソースが足りない、代わりに誰か試してほしい、猫の手も借りたい、といった課題を持っていらっしゃる方は多いのではないでしょうか?それこそまさに猫ではなく生成AIの手を借りるべきタスクなのではないでしょうか?ということで、今回はCodex CLIで論文の再現実装を試した結果についてご紹介します。
TL;DR
- Codex CLIで実証分析を行っている論文の結果が再現できるかを検証
- モデルはGPT-5-Codexを利用し、要件定義書をベースにコーディングを指示
- 期待した通り、もしくはそれ以上の実装を行ってくれることを確認
- ただし再現結果が合わない時は生成AIの実装が間違っているのか論文が怪しいかの判断が必要
対象の論文と選定理由
今回は第34回 人工知能学会 金融情報学研究会(SIG-FIN)で発表があった、「重み付きTsallisエントロピー正則化に基づくリスクパリティ・ポートフォリオの一般化」の論文を対象としました。
この論文を選んだ理由としては、
- ポートフォリオ最適化の実証分析で再現実装を行うタスクとして適している
- 論文中に最適化アルゴリズムや分析条件などが詳細に記述されている
- 論文で用いているデータがオープンに取得できる
などが挙げられ、まさに今回の検証においてうってつけの論文でした。
今回は論文中の"表1: Performance Metrics Comparison"の再現実装を目指します。

準備
Codex CLI
まずはCodex CLIのインストールをして、APIキーでGPTを使えるようにするまでです。今回はAzure OpenAIを使用するため、下記のサイトを参考にしました。
また、モデルはGPT-5-Codexのmediumを利用しています。モデルの詳細については例えば下記の記事などで解説されていますのでご参照ください。
利用データ
データは論文と同様にKenneth R. French - Data Libraryから取得しています。
今回はあらかじめダウンロードし、分析環境のローカルフォルダにcsvを置きました。
- FF25 : 25_Portfolios_5x5.csv
- FF48 : 48_Industry_Portfolios.csv
- FF100 : 100_Portfolios_10x10.csv
要件定義書
ChatGPTに協力してもらい、論文から以下のようなマークダウン形式の要件定義書を作成しました。Codex CLIで再現実装する際に、元の論文は参照させず、こちらの要件定義.mdのみを参照してCodex CLIでコーディングを行っています。
要件定義書.md(クリックすると表示)
# 要件定義書: 表1 Performance Metrics Comparison 再現実装
## 1. 目的
本要件定義書は、論文「Generalizing Risk Parity Portfolios with Weighted Tsallis Entropy Regularization」における **表1: Performance Metrics Comparison** を再現するための実装仕様を定義する。
EW, MV, RB, RBT の各ポートフォリオ構築手法を再現し、年率リターン、リスク、最大ドローダウン等の評価指標を算出・比較する。
---
## 2. 使用データ
- **データセット:** Fama and French (FF) データセット
- FF25, FF48, FF100 の月次リターン
- 期間: 1990年1月〜2024年8月
- **データ取得先:**
[Ken French Data Library](https://mba.tuck.dartmouth.edu/pages/faculty/ken.french/data_library.html)
- **CSVファイル**
- FF25: ./data/25_Portfolios_5x5.csv
- FF48: ./data/48_Industry_Portfolios.csv
- FF100: ./data/100_Portfolios_10x10.csv
---
## 3. ポートフォリオ構築手法
### 3.1 Equal Weighted Portfolio (EW)
- 各資産に等ウェイトを付与
\[
w_i = \frac{1}{N}, \quad i = 1, \dots, N
\]
### 3.2 Minimum Variance Portfolio (MV)
- 最小分散ポートフォリオを解く
**目的関数**
\[
\min_{w \in \mathbb{R}^N} \ \sigma_P^2(w) = w^\top \Sigma w
\]
**制約条件**
\[
\sum_{i=1}^N w_i = 1
\]
### 3.3 Risk Budgeting Portfolio (RB)
- 各資産 i のリスク寄与度 \(RC_i\) が事前設定したリスクバジェット \(b_i\) に一致するよう最適化
**目的関数**
\[
\min_{w \in \mathbb{R}^N} \ \sum_{i=1}^N (RC_i - b_i)^2
\]
**制約条件**
\[
w > 0, \quad RC_i = \frac{w_i (\Sigma w)_i}{\sigma_P^2(w)}
\]
- **リスクバジェットの設定**
- 12か月モメンタムに基づき設定
\[
k_{i,t} = z_t \cdot \text{rank}(M^i_t), \quad
z_t = \frac{2}{N_t (N_t + 1)}
\]
### 3.4 提案手法 (RBT)
- Tsallisエントロピー正則化付きリスクバジェッティング
**目的関数**
\[
\min_{w} \ \sigma_P^2(w) - \lambda M_q(w)
\]
- **Weighted Tsallis Entropy**
\[
M_q(w) = \sum_{i=1}^N k_i \frac{1 - w_i^q}{q-1}
\]
- **制約条件**
\[
w > 0
\]
- **パラメータ設定**
- \(q \in \{1.5, 2.0, 2.5\}\)
- \(\lambda \in \{0.001, 0.01, 0.1\}\)
- 過去120か月のリターンを用いたローリング最適化
- 毎月リバランス時に **R/R最大** のパラメータ組み合わせを選択
---
## 4. 評価指標
### 4.1 年率リターン (AR)
\[
AR = \frac{12}{T} \sum_{t=1}^T R_t
\]
### 4.2 年率リスク (RISK)
\[
RISK = \sqrt{\frac{12}{T-1} \sum_{t=1}^T (R_t - \mu)^2}
\]
### 4.3 リスク・リターン比 (R/R)
\[
R/R = \frac{AR}{RISK}
\]
### 4.4 最大ドローダウン (MAXDD)
\[
MDD = \min_{t} \left( 0, \frac{W_t}{\max_{\tau \le t} W_\tau} - 1 \right), \quad
W_t = \prod_{t'=1}^t (1+R_{t'})
\]
### 4.5 Calmar Ratio (CR)
\[
CR = \frac{AR}{|MDD|}
\]
### 4.6 ハーフィンダール指数 (HI)
\[
HI = \frac{1}{T} \sum_{t=1}^T \sum_{i=1}^N w_{i,t}^2
\]
---
## 5. 実装要件
- **最適化ソルバ:**
- SciPy (SLSQP) または CVXOPT を利用
- 勾配は自動微分または手計算で実装
- **ローリング計算:**
- 共分散行列は直近120か月のデータから計算
- 毎月リバランスし、翌月リターンで評価
- **パフォーマンス集計:**
- 各ポートフォリオ手法について、FF25 / FF48 / FF100 のデータセットごとに
AR, RISK, R/R, MAXDD, CR, HI を算出
---
## 6. 出力フォーマット
最終的な出力は、以下のようなマークダウン表として整形する。
| Dataset | Metric | EW | MV | RB | RBT |
|--------|--------|----|----|----|----|
| FF25 | AR | 0.1094 | 0.1153 | 0.1138 | 0.1125 |
| | RISK | 0.1885 | 0.2085 | 0.1752 | 0.1603 |
| | R/R | 0.5806 | 0.5529 | 0.6495 | 0.7016 |
| | MAXDD | -0.5431 | -0.6541 | -0.5501 | -0.5283 |
| | CR | 0.2015 | 0.1763 | 0.2068 | 0.2129 |
| | HI | 0.0400 | 0.5569 | 0.0744 | 0.1843 |
| FF48 | AR | ... | ... | ... | ... |
| ... | ... | ... | ... | ... | ... |
※ 実際の数値は計算結果を埋め込む。
出来上がったコード
最初の指示文は以下の通り、シンプルな形にしています。
要件定義書.mdを元に再現実装を行ってください。
データはdata直下にあるcsvファイルを利用してください。
再現にあたって必要なライブラリ等はPoetryで管理してください。
最適化がうまく行かない場合の終了条件も設定してください。
そのあとは、たまに承認(Yesを選ぶ)を繰り返すだけで以下のコードを作成してくれました。パッと見ただけでも、適切に関数化しており処理に応じてpyファイルを切り分けるなど、かなり出来上がりが良い印象です。
backtest.py
"""Rolling backtest implementation for the reproduction study."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, Iterable, Sequence
import numpy as np
import pandas as pd
from . import metrics
from .portfolio import (
OptimizationResult,
equal_weight,
minimum_variance,
risk_budgeting,
tsallis_risk_budgeting,
)
LOOKBACK_MONTHS = 120
MOMENTUM_WINDOW = 12
TSALLIS_Q = (1.5, 2.0, 2.5)
TSALLIS_LAMBDA = (0.001, 0.01, 0.1)
@dataclass
class BacktestResult:
returns: pd.Series
weights: pd.DataFrame
success: pd.Series
def compute_momentum_budgets(history: pd.DataFrame) -> np.ndarray:
tail = history.tail(MOMENTUM_WINDOW)
momentum = (1.0 + tail).prod() - 1.0
ranks = momentum.rank(method="first", ascending=True)
n_assets = len(momentum)
z = 2.0 / (n_assets * (n_assets + 1.0))
budgets = z * ranks.to_numpy(dtype=float)
budgets /= budgets.sum()
return budgets
def _select_tsallis_weights(
cov: np.ndarray,
budgets: np.ndarray,
history: pd.DataFrame,
initial: np.ndarray | None,
) -> OptimizationResult:
best_ratio = -np.inf
best_result: OptimizationResult | None = None
for q in TSALLIS_Q:
for lam in TSALLIS_LAMBDA:
candidate = tsallis_risk_budgeting(
cov, budgets, q, lam, initial=initial
)
if not candidate.success:
continue
in_sample = history.to_numpy() @ candidate.weights
rr = metrics.risk_return_ratio(in_sample)
if not np.isfinite(rr):
continue
if rr > best_ratio:
best_ratio = rr
best_result = candidate
if best_result is None:
n_assets = cov.shape[0]
return OptimizationResult(
weights=np.full(n_assets, 1.0 / n_assets), success=False
)
return OptimizationResult(weights=best_result.weights, success=True)
def run_backtest(
returns: pd.DataFrame,
method: str,
*,
lookback: int = LOOKBACK_MONTHS,
) -> BacktestResult:
if returns.index.freq is None:
returns = returns.copy()
returns.index = pd.PeriodIndex(returns.index, freq="M").to_timestamp()
dates = returns.index
n_assets = returns.shape[1]
weights_history: list[pd.Series] = []
returns_history: list[float] = []
success_history: list[bool] = []
previous_weights = equal_weight(n_assets).weights
for idx in range(lookback, len(dates)):
date = dates[idx]
history = returns.iloc[idx - lookback : idx]
cov = history.cov().to_numpy()
current = returns.iloc[idx]
if method == "EW":
result = equal_weight(n_assets)
elif method == "MV":
result = minimum_variance(cov, initial=previous_weights)
elif method == "RB":
budgets = compute_momentum_budgets(history)
result = risk_budgeting(cov, budgets, initial=previous_weights)
elif method == "RBT":
budgets = compute_momentum_budgets(history)
result = _select_tsallis_weights(
cov, budgets, history, initial=previous_weights
)
else:
raise ValueError(f"Unknown method: {method}")
if not result.success:
weights = previous_weights
success = False
else:
weights = result.weights
previous_weights = weights
success = True
portfolio_return = float(np.dot(current.to_numpy(), weights))
weights_history.append(pd.Series(weights, index=returns.columns, name=date))
returns_history.append(portfolio_return)
success_history.append(success)
realized_returns = pd.Series(returns_history, index=dates[lookback:])
weight_df = pd.DataFrame(weights_history)
success_series = pd.Series(success_history, index=dates[lookback:])
return BacktestResult(returns=realized_returns, weights=weight_df, success=success_series)
def run_all_methods(returns: pd.DataFrame) -> Dict[str, BacktestResult]:
methods = ("EW", "MV", "RB", "RBT")
return {method: run_backtest(returns, method) for method in methods}
__all__ = ["BacktestResult", "run_backtest", "run_all_methods"]
data.py
"""Data loading helpers for the Fama-French CSV files."""
from __future__ import annotations
from pathlib import Path
from typing import Iterable
import numpy as np
import pandas as pd
def _detect_header_line(lines: Iterable[str]) -> int:
"""Return the index of the header line in a raw Ken French CSV file."""
for idx, line in enumerate(lines):
if line.lstrip().startswith(","):
return idx
raise ValueError("CSV header line not found in file")
def load_ff_dataset(
path: str | Path,
*,
start: str = "1990-01",
end: str = "2024-08",
) -> pd.DataFrame:
"""Load a Fama-French portfolio dataset and return monthly returns.
The loader trims textual headers, keeps rows that correspond to YYYYMM
timestamps, converts percentages to decimal returns, and restricts the
sample to the requested period.
"""
csv_path = Path(path)
if not csv_path.exists():
raise FileNotFoundError(csv_path)
with csv_path.open("r", encoding="utf-8-sig") as handle:
raw_lines = handle.readlines()
header_idx = _detect_header_line(raw_lines)
df = pd.read_csv(csv_path, skiprows=header_idx, header=0)
first_col = df.columns[0]
df = df.rename(columns={first_col: "date"})
df["date"] = df["date"].astype(str).str.strip()
mask = df["date"].str.fullmatch(r"\d{6}")
df = df.loc[mask].copy()
df.loc[:, df.columns[1:]] = df.loc[:, df.columns[1:]].apply(
pd.to_numeric, errors="coerce"
)
df.replace({-99.99: np.nan, -999.0: np.nan}, inplace=True)
df["date"] = pd.to_datetime(df["date"], format="%Y%m")
df = df.set_index("date").sort_index()
df = df.loc[start:end]
df.columns = [col.strip() for col in df.columns]
df = df.dropna(how="any")
df = df / 100.0
return df
__all__ = ["load_ff_dataset"]
metrics.py
"""Performance metric helpers."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Iterable
import numpy as np
import pandas as pd
def annual_return(returns: Iterable[float]) -> float:
arr = np.asarray(list(returns), dtype=float)
if arr.size == 0:
return float("nan")
return float(arr.mean() * 12.0)
def annual_risk(returns: Iterable[float]) -> float:
arr = np.asarray(list(returns), dtype=float)
if arr.size <= 1:
return float("nan")
return float(arr.std(ddof=1) * np.sqrt(12.0))
def risk_return_ratio(returns: Iterable[float]) -> float:
risk = annual_risk(returns)
if not np.isfinite(risk) or risk == 0:
return float("nan")
return annual_return(returns) / risk
def max_drawdown(returns: Iterable[float]) -> float:
arr = np.asarray(list(returns), dtype=float)
if arr.size == 0:
return float("nan")
wealth = np.cumprod(1.0 + arr)
running_max = np.maximum.accumulate(wealth)
drawdowns = wealth / running_max - 1.0
return float(drawdowns.min())
def calmar_ratio(returns: Iterable[float]) -> float:
mdd = max_drawdown(returns)
if not np.isfinite(mdd) or mdd == 0:
return float("nan")
return annual_return(returns) / abs(mdd)
def herfindahl_index(weights: pd.DataFrame) -> float:
if weights.empty:
return float("nan")
squared_sum = (weights**2).sum(axis=1)
return float(squared_sum.mean())
def compute_all_metrics(
returns: pd.Series,
weights: pd.DataFrame,
) -> dict[str, float]:
metrics = {
"AR": annual_return(returns.values),
"RISK": annual_risk(returns.values),
"R/R": risk_return_ratio(returns.values),
"MAXDD": max_drawdown(returns.values),
"CR": calmar_ratio(returns.values),
"HI": herfindahl_index(weights),
}
return metrics
__all__ = [
"annual_return",
"annual_risk",
"risk_return_ratio",
"max_drawdown",
"calmar_ratio",
"herfindahl_index",
"compute_all_metrics",
]
portfolio.py
"""Portfolio construction routines for the reproduction study."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Iterable, Sequence
import numpy as np
from numpy.typing import NDArray
from scipy.optimize import minimize
EPSILON = 1e-9
MAX_ITER = 200
FTOL = 1e-6
JITTER = 1e-6
@dataclass
class OptimizationResult:
weights: NDArray[np.float64]
success: bool
def _normalize(weights: NDArray[np.float64]) -> NDArray[np.float64]:
positive = np.clip(weights, 0.0, None)
total = positive.sum()
if total <= 0:
size = weights.shape[0]
return np.full(size, 1.0 / size)
return positive / total
def ensure_symmetric(cov: NDArray[np.float64]) -> NDArray[np.float64]:
return (cov + cov.T) / 2.0
def prepare_covariance(cov: NDArray[np.float64]) -> NDArray[np.float64]:
sym = ensure_symmetric(cov)
return sym + np.eye(sym.shape[0]) * JITTER
def risk_contributions(
weights: NDArray[np.float64],
cov: NDArray[np.float64],
) -> NDArray[np.float64]:
portfolio_var = float(weights @ cov @ weights)
if portfolio_var <= 0:
return np.full_like(weights, np.nan)
marginal = cov @ weights
return weights * marginal / portfolio_var
def equal_weight(n_assets: int) -> OptimizationResult:
weights = np.full(n_assets, 1.0 / n_assets)
return OptimizationResult(weights=weights, success=True)
def minimum_variance(
cov: NDArray[np.float64],
*,
initial: NDArray[np.float64] | None = None,
) -> OptimizationResult:
n_assets = cov.shape[0]
cov = prepare_covariance(cov)
def objective(w: NDArray[np.float64]) -> float:
return float(w @ cov @ w)
constraints = ({"type": "eq", "fun": lambda w: np.sum(w) - 1.0},)
bounds = tuple((0.0, 1.0) for _ in range(n_assets))
if initial is None:
x0 = np.full(n_assets, 1.0 / n_assets)
else:
x0 = _normalize(initial)
result = minimize(
objective,
x0=x0,
method="SLSQP",
bounds=bounds,
constraints=constraints,
options={"maxiter": MAX_ITER, "ftol": FTOL},
)
if not result.success:
weights = np.full(n_assets, 1.0 / n_assets)
return OptimizationResult(weights=weights, success=False)
weights = _normalize(result.x)
return OptimizationResult(weights=weights, success=True)
def risk_budgeting(
cov: NDArray[np.float64],
budgets: NDArray[np.float64],
*,
initial: NDArray[np.float64] | None = None,
) -> OptimizationResult:
n_assets = cov.shape[0]
cov = prepare_covariance(cov)
budgets = _normalize(budgets)
def objective(w: NDArray[np.float64]) -> float:
rc = risk_contributions(w, cov)
if not np.all(np.isfinite(rc)):
return 1e6
return float(np.sum((rc - budgets) ** 2))
constraints = ({"type": "eq", "fun": lambda w: np.sum(w) - 1.0},)
bounds = tuple((EPSILON, 1.0) for _ in range(n_assets))
if initial is None:
x0 = np.full(n_assets, 1.0 / n_assets)
else:
x0 = _normalize(initial)
result = minimize(
objective,
x0=x0,
method="SLSQP",
bounds=bounds,
constraints=constraints,
options={"maxiter": MAX_ITER, "ftol": FTOL},
)
if not result.success:
weights = np.full(n_assets, 1.0 / n_assets)
return OptimizationResult(weights=weights, success=False)
weights = _normalize(result.x)
return OptimizationResult(weights=weights, success=True)
def weighted_tsallis_entropy(
weights: NDArray[np.float64],
k_weights: NDArray[np.float64],
q: float,
) -> float:
w = np.clip(weights, EPSILON, None)
return float(np.sum(k_weights * (1.0 - np.power(w, q)) / (q - 1.0)))
def tsallis_risk_budgeting(
cov: NDArray[np.float64],
k_weights: NDArray[np.float64],
q: float,
lam: float,
*,
initial: NDArray[np.float64] | None = None,
) -> OptimizationResult:
if q <= 1.0:
raise ValueError("q must be greater than 1")
n_assets = cov.shape[0]
cov = prepare_covariance(cov)
k_norm = _normalize(k_weights)
def objective(w: NDArray[np.float64]) -> float:
portfolio_var = float(w @ cov @ w)
entropy = weighted_tsallis_entropy(w, k_norm, q)
return portfolio_var - lam * entropy
constraints = ({"type": "eq", "fun": lambda w: np.sum(w) - 1.0},)
bounds = tuple((EPSILON, 1.0) for _ in range(n_assets))
if initial is None:
x0 = np.full(n_assets, 1.0 / n_assets)
else:
x0 = _normalize(initial)
result = minimize(
objective,
x0=x0,
method="SLSQP",
bounds=bounds,
constraints=constraints,
options={"maxiter": MAX_ITER, "ftol": FTOL},
)
if not result.success:
weights = np.full(n_assets, 1.0 / n_assets)
return OptimizationResult(weights=weights, success=False)
weights = _normalize(result.x)
return OptimizationResult(weights=weights, success=True)
__all__ = [
"OptimizationResult",
"equal_weight",
"minimum_variance",
"risk_budgeting",
"tsallis_risk_budgeting",
"risk_contributions",
"weighted_tsallis_entropy",
]
report.py
"""Generate the reproduction table for the study."""
from __future__ import annotations
from pathlib import Path
from typing import Dict
import pandas as pd
from .backtest import run_all_methods
from .data import load_ff_dataset
from .metrics import compute_all_metrics
DATASET_FILES = {
"FF25": "25_Portfolios_5x5.csv",
}
METRIC_ORDER = ("AR", "RISK", "R/R", "MAXDD", "CR", "HI")
def generate_report(data_dir: str | Path | None = None) -> pd.DataFrame:
"""Run all backtests and return a formatted metrics table."""
if data_dir is None:
data_dir = Path(__file__).resolve().parents[2] / "data"
else:
data_dir = Path(data_dir)
results: Dict[str, Dict[str, dict[str, float]]] = {}
for dataset, filename in DATASET_FILES.items():
returns = load_ff_dataset(data_dir / filename)
method_results = run_all_methods(returns)
metrics_by_method = {
method: compute_all_metrics(result.returns, result.weights)
for method, result in method_results.items()
}
results[dataset] = metrics_by_method
index = pd.MultiIndex.from_tuples(
[(dataset, metric) for dataset in DATASET_FILES for metric in METRIC_ORDER],
names=["Dataset", "Metric"],
)
columns = ["EW", "MV", "RB", "RBT"]
data = []
for dataset in DATASET_FILES:
for metric in METRIC_ORDER:
row = [results[dataset][method][metric] for method in columns]
data.append(row)
table = pd.DataFrame(data, index=index, columns=columns)
return table
def main() -> None:
table = generate_report()
print(table.to_markdown(floatfmt=".4f"))
if __name__ == "__main__":
main()
ちなみに、ここまでの生成にかかったTotalトークン数は約23万トークンおよびキャッシュで約124万トークンで、所要時間は30分ほどでした。自分で手を動かして(ここまできれいにリファクタリングまで含めて)実装すると余裕で丸一日は超えそうなので、そう考えるとかなりお得なのではないでしょうか?

出力結果と人による確認
とりあえず一通り実装が終わった後に、report.pyを実行すると以下の結果が出力されました。もともとFF25,FF48,FF100の3パターン全て試す予定でしたが、(後述する通り1発でうまく行かなかったので)とりあえず出力はFF25のみで見ています。
| EW | MV | RB | RBT | |
|---|---|---|---|---|
| ('FF25', 'AR') | 135.7656 | 6.9401 | 26.0816 | 6.9422 |
| ('FF25', 'RISK') | 124.5713 | 3.6166 | 18.5547 | 3.6172 |
| ('FF25', 'R/R') | 1.0899 | 1.9189 | 1.4057 | 1.9192 |
| ('FF25', 'MAXDD') | nan | nan | nan | nan |
| ('FF25', 'CR') | nan | nan | nan | nan |
| ('FF25', 'HI') | 0.0400 | 0.8480 | 0.0916 | 0.8447 |
明らかにARやRISKが大きすぎ、MAXDDがNaNになっている、など不可解な結果になっています。さすがにこれは再現実装のほうで何かしら問題があるため原因を究明する必要があります。色々と確認を行った結果、もともと与えていたcsvファイルの中身が実装で想定していたものと異なっていたことが原因でした。(それでも動いてしまうコードを書くのがスゴイですが・・・)
ファイルを差し替えて再実行した結果は以下の通りとなりました。
| EW | MV | RB | RBT | |
|---|---|---|---|---|
| ('FF25', 'AR') | 0.1091 | 0.1127 | 0.1164 | 0.1154 |
| ('FF25', 'RISK') | 0.1880 | 0.1523 | 0.1807 | 0.1603 |
| ('FF25', 'R/R') | 0.5803 | 0.7399 | 0.6440 | 0.7203 |
| ('FF25', 'MAXDD') | -0.5395 | -0.5272 | -0.5115 | -0.5813 |
| ('FF25', 'CR') | 0.2023 | 0.2137 | 0.2275 | 0.1986 |
| ('FF25', 'HI') | 0.0400 | 0.2506 | 0.0535 | 0.2525 |
今一度、表1のFF25の結果と比較してみます。

数値のスケール感は良い感じになっており、論文で主張されているRBTの優位性(RISKが低くR/Rが良いなど)についても、ある程度整合的な結果になっていると思われます。またEW(※等ウエイトポートフォリオで最適化を行わないシンプルな結果)を見るとほとんど結果が一致しているため、使っているデータや評価指標の定義についてはおおむね問題が無さそうであることが分かります。一方で、例えばMVにおいてはRISKが論文より低めに出ている、RBTのMAXDDが論文より大きい、など所々で乖離があることから、最適化のアルゴリズムの実装は論文と異なっている可能性があります。これについては、論文内に詳細なパラメータや利用ライブラリまでは言及されていないため、仕方のない部分でもあるかと思います。(もちろん、要件定義の作成段階で情報が欠落してしまっている可能性も否めず、真面目に検証しようとすると論文の記載内容を詳細にチェックする必要があります。)
以上をまとめると、程度問題はあるものの明らかに結果の乖離が大きすぎる場合は、生成AIの実装が想定と異なっているか、論文側が間違っているかのいずれかであり、再現結果が一致しなかった時の実装チェックや詳細な原因深堀りなどは人が行う必要があると考えられます。例えば、今回であれば最終出力だけでなく、途中のbacktest.pyのrun_backtest関数の出力(=ポートフォリオリターンや最適化されたウエイトの時系列推移)などを見ていく必要がありますが、そのためにはやはり生成AIが書いたコードの理解や、タスク(=ドメイン知識)の理解など、そもそものベースとなるスキルセットが必要となります。
課題や展望など
今回の再現実装は仕様駆動開発(SDD;Spec-Driven Development)の考え方に基づいて試してみました。理想を言えば、KiroやSpec Kitのように、要件定義の作成から一気通貫で出来るようになると良いなと思っています。最近は下記のようなCodex CLI対応の仕様駆動開発ツールが出てきたりしているので、こういったものを活用して良ければと考えています。
一方で、SDDと異なる点として再現実装のためには、まずは論文(pdf)を読み込んで内容を理解させる必要があります。場合によっては論文中にある図や表などの理解まで必要になる可能性があるため、MCPをうまく使う必要も出てくるのではないかと考えております。
また、今回はデータが簡単に準備できたので再現実装が可能でしたが、そもそもデータがOpenに存在しない、取得&加工が難しい場合などはどうしようもない気がしています。とは言いつつも、機械学習のタスクであればベンチマークやリーダーボードが存在しており、再現実装を試みることが出来るのではないかと考えております。その際にはWeb検索機能を組み合わせて、データも自動で取ってくるところまで出来るとすごく助かるな~と思っています。(※今でも既に出来るのかもしれません。ご存じの方がいらっしゃいましたら教えていただけますと幸いです。汗)
さいごに
今回は論文の再現実装タスクに着目して、生成AIの利用可能性について検証してみましたが、なかなか期待が持てる結果になったと考えております。特に、査読の負担を減らして尚且つクオリティを上げるといった場面でものすごく活躍しそうな気がしていますが、当然ながら生成AIの結果を100%信用してはいけないと改めて思い直すきっかけとなりました。
MTECではこの他にも、様々な生成AIやその周辺のサービスの金融領域における利用可能性についても検証をおこなっております。興味のある方は是非ご連絡ください。最後までお読みいただきありがとうございました!
Discussion