# HISTORY.md

仕様変更の提案・決定・廃止の履歴。1変更につき1エントリ。

---

## 2026-03-04: PyPI 配布要件の明文化

- ID: `H-0000`
- Status: `accepted`
- Scope: `Config | Packaging`
- Related: `BLUEPRINT.md §15.4, §18.2`

### Context

PyPI 公開を前提にした場合、build 定義・配布メタデータ・README・optional dependency・CI 検証の要件が明文化されていないとリリース品質がぶれる。

### Proposal

- `pyproject.toml` に PEP 517/518 準拠の `[build-system]` を定義し、`sdist / wheel` を生成できるようにする。
- `[project]` に name / version / description / readme / requires-python / license / authors / classifiers / urls を必須で記載する。
- optional dependency を `[project.optional-dependencies]`（配布利用者向け）と `[dependency-groups]`（開発者向け）に分離する。
- `README.md` の import 例を実際のパッケージ名と一致させる。
- `py.typed` を同梱して PEP 561 に準拠する。

### Impact

- `pyproject.toml` / `README.md` の変更のみ。公開 API の shape は変更しない。

### Compatibility

- 破壊的変更なし。配布契約とドキュメント契約を追加するのみ。

### Alternatives Considered

- 実装時に都度判断し仕様に書かない → 担当者ごとの判断に依存してリリース品質がぶれるため却下。

### Acceptance Criteria

- `uv build` で sdist / wheel が生成できる。
- `twine check` が PASSED になる。
- `lizyml/py.typed` が存在する。
- README の import 例がパッケージ名と一致している。
- BLUEPRINT §15.4 / §18.2 に要件が追加されている。

### Decision

- Date: `2026-03-04`
- Result: `accepted`
- Notes: BLUEPRINT §15.4 / §18.1 / §18.2 に反映済み。`fix/phase-0-pypi-compliance` ブランチで実施。

---

## 2026-03-04: Config Schema の全フィールド確定

- ID: `H-0001`
- Status: `accepted`
- Scope: `Config`
- Related: `BLUEPRINT.md §5, §3.3`

### Context

Phase 2 でpydantic v2 スキーマを実装する前に、LizyMLConfig の全フィールドとバリデーション方針を仕様として固定する必要がある。未確定のままスキーマを実装すると、後から Config のキーや型を変更するたびに破壊的変更が生じる。

### Proposal

`LizyMLConfig`（トップレベル）の全フィールドと各 sub-config を以下の通り確定する。

#### トップレベル

```python
class LizyMLConfig(BaseModel):
    model_config = ConfigDict(extra="forbid")
    config_version: int                      # 必須。将来の config schema 変更を追跡
    task: Literal["regression", "binary", "multiclass"]
    data: DataConfig
    features: FeaturesConfig
    split: SplitConfig
    model: Annotated[ModelConfig, Field(discriminator="name")]  # lgbm / (将来 sklearn 等)
    training: TrainingConfig
    tuning: Optional[TuningConfig] = None
    evaluation: EvaluationConfig
    calibration: Optional[CalibrationConfig] = None
```

#### DataConfig

```python
class DataConfig(BaseModel):
    model_config = ConfigDict(extra="forbid")
    path: str | None = None          # CSV / Parquet ファイルパス（DataFrame 渡し時は None）
    target: str                      # 目的変数列名
    time_col: str | None = None      # 時系列列名（時系列分割時に必須）
    group_col: str | None = None     # グループ列名（グループ分割時に必須）
```

#### FeaturesConfig

```python
class FeaturesConfig(BaseModel):
    model_config = ConfigDict(extra="forbid")
    exclude: list[str] = []          # 学習から除外する列
    auto_categorical: bool = True    # 非数値列を自動でカテゴリ扱いにする
    categorical: list[str] = []      # 明示的にカテゴリ指定する列
```

#### SplitConfig（discriminated union）

```python
class KFoldConfig(BaseModel):
    model_config = ConfigDict(extra="forbid")
    method: Literal["kfold"]
    n_splits: int = 5
    random_state: int = 42
    shuffle: bool = True

class StratifiedKFoldConfig(BaseModel):
    model_config = ConfigDict(extra="forbid")
    method: Literal["stratified_kfold"]
    n_splits: int = 5
    random_state: int = 42

class GroupKFoldConfig(BaseModel):
    model_config = ConfigDict(extra="forbid")
    method: Literal["group_kfold"]
    n_splits: int = 5

class TimeSeriesConfig(BaseModel):
    model_config = ConfigDict(extra="forbid")
    method: Literal["time_series"]
    n_splits: int = 5
    gap: int = 0

SplitConfig = Annotated[
    KFoldConfig | StratifiedKFoldConfig | GroupKFoldConfig | TimeSeriesConfig,
    Field(discriminator="method"),
]
```

#### ModelConfig（discriminated union）

```python
class LGBMConfig(BaseModel):
    model_config = ConfigDict(extra="forbid")
    name: Literal["lgbm"]
    params: dict[str, Any] = {}

ModelConfig = Annotated[LGBMConfig, Field(discriminator="name")]
```

#### TrainingConfig

```python
class HoldoutInnerValidConfig(BaseModel):
    model_config = ConfigDict(extra="forbid")
    method: Literal["holdout"]
    ratio: float = 0.1
    random_state: int = 42

class EarlyStoppingConfig(BaseModel):
    model_config = ConfigDict(extra="forbid")
    enabled: bool = False
    rounds: int = 50
    inner_valid: HoldoutInnerValidConfig | None = None
    validation_ratio: float | None = None  # inner_valid.ratio のエイリアス (H-0010)

class TrainingConfig(BaseModel):
    model_config = ConfigDict(extra="forbid")
    seed: int = 42
    early_stopping: EarlyStoppingConfig = EarlyStoppingConfig()
```

#### TuningConfig

```python
class OptunaParamsConfig(BaseModel):
    model_config = ConfigDict(extra="forbid")
    n_trials: int = 50
    direction: Literal["minimize", "maximize"] = "minimize"
    timeout: float | None = None

class OptunaConfig(BaseModel):
    model_config = ConfigDict(extra="forbid")
    params: OptunaParamsConfig = OptunaParamsConfig()
    space: dict[str, Any] = {}

class TuningConfig(BaseModel):
    model_config = ConfigDict(extra="forbid")
    optuna: OptunaConfig = OptunaConfig()
```

#### EvaluationConfig

```python
class EvaluationConfig(BaseModel):
    model_config = ConfigDict(extra="forbid")
    metrics: list[str] = []          # 例: ["rmse", "mae"]
```

#### CalibrationConfig

```python
class CalibrationConfig(BaseModel):
    model_config = ConfigDict(extra="forbid")
    method: Literal["platt", "isotonic", "beta"] = "platt"
    n_splits: int = 5                # calibration cross-fit の fold 数
```

#### バリデーション方針

- 全 sub-config に `extra="forbid"` を適用してタイポを必ずエラー化する。
- `config_version` は必須とし、将来のスキーマ変更時に `CONFIG_VERSION_UNSUPPORTED` で拒否できるようにする。
- 不正な Config は `LizyMLError(CONFIG_INVALID)` として統一的に扱う（pydantic の ValidationError をラップ）。
- loader 層で alias 正規化（例: `k-fold` → `kfold`）を行い、スキーマ validate 前に適用する。
- 環境変数 override: `LIZYML__` prefix、`__` でネスト区切り（例: `LIZYML__model__lgbm__params__learning_rate=0.01`）。

### Impact

- `lizyml/config/schema.py` の新規実装。
- `lizyml/config/loader.py` の新規実装。
- `lizyml/core/specs/` 以下の Spec クラス群の新規実装。
- `lizyml/core/registries.py` の新規実装。
- `tests/test_config/` の新規テスト群。

### Compatibility

- 新規実装につき既存コードへの破壊的影響なし。
- 将来 Config スキーマを変更する際は `config_version` を上げ、migration 方針を本 HISTORY.md に記録する。

### Alternatives Considered

- marshmallow / cerberus 等の他バリデーションライブラリ → pydantic v2 は `extra="forbid"` とdiscriminated union により typo 検知と型安全性が高いため採用。
- Config を dict のまま扱う → 未知キーを検知できず契約が壊れるため却下。

### Acceptance Criteria

- `LizyMLConfig` が正常 dict から生成できる。
- 未知キー混入時に `CONFIG_INVALID` が返る。
- `config_version` 欠落時に ValidationError が返る。
- YAML / JSON / dict 各形式からのロードが成功する。
- 環境変数 override が動作する。
- alias 正規化（`k-fold` → `kfold`）が機能する。
- Config → 各 Spec 変換の網羅テストが通過する。

---

## 2026-03-04: FitResult / PredictionResult / Artifacts の全フィールド確定

- ID: `H-0002`
- Status: `accepted`
- Scope: `Artifacts`
- Related: `BLUEPRINT.md §7`

### Context

Phase 4 でデータクラスを実装する前に、FitResult / PredictionResult / Artifacts の全フィールドと shape・意味・階層を仕様として固定する。スキーマ確定前に実装すると後からの変更が破壊的変更となり format_version を上げる必要が生じる。

### Proposal

#### FitResult

```python
@dataclass
class FitResult:
    oof_pred: np.ndarray          # shape: (n_samples,) regression/binary, (n_samples, n_classes) multiclass
    if_pred_per_fold: list[np.ndarray]  # len == n_splits, 各要素は train fold 全体の予測
    metrics: dict                  # {"raw": {"oof": {...}, "if_mean": {...}, "if_per_fold": [...]},
                                   #  "calibrated": {...}}  # binary + calibrator 有効時のみ
    models: list[Any]              # fold ごとのモデル（EstimatorAdapter 内包）
    history: list[dict]            # per-fold: {"eval_history": ..., "best_iteration": int}
    feature_names: list[str]       # 学習に使用した特徴量名（順序固定）
    dtypes: dict[str, str]         # 特徴量名 → dtype 文字列
    categorical_features: list[str]  # カテゴリ特徴量名
    splits: SplitIndices           # 外側 CV / inner valid / calibration の全 indices
    data_fingerprint: DataFingerprint  # データ同一性の検証用
    pipeline_state: Any            # FeaturePipeline の保存状態
    calibrator: Any | None         # binary + calibration 有効時のみ
    run_meta: RunMeta              # バージョン・Config 情報
```

#### PredictionResult

```python
@dataclass
class PredictionResult:
    pred: np.ndarray               # shape: (n_samples,)
    proba: np.ndarray | None       # binary のみ、shape: (n_samples,)
    shap_values: np.ndarray | None # 要求時のみ、shape: (n_samples, n_features)
    used_features: list[str]       # 実際に使用した特徴量名
    warnings: list[str]            # 列ズレ等の補正通知
```

#### SplitIndices

```python
@dataclass
class SplitIndices:
    outer: list[tuple[np.ndarray, np.ndarray]]  # fold ごとの (train_idx, valid_idx)
    inner: list[tuple[np.ndarray, np.ndarray]] | None  # fold ごとの inner valid
    calibration: list[tuple[np.ndarray, np.ndarray]] | None  # calibration CV indices
```

#### RunMeta

```python
@dataclass
class RunMeta:
    lizyml_version: str
    python_version: str
    deps_versions: dict[str, str]   # {"lightgbm": "4.x.x", "pydantic": "2.x.x", ...}
    config_normalized: dict          # ロード時に正規化済みの Config dict
    config_version: int
    run_id: str                      # UUID
    timestamp: str                   # ISO 8601
```

#### metrics の階層（固定）

```python
{
    "raw": {
        "oof": {"rmse": float, "mae": float, ...},
        "if_mean": {"rmse": float, ...},
        "if_per_fold": [{"rmse": float, ...}, ...]   # len == n_splits
    },
    "calibrated": {  # binary + calibrator 有効時のみ存在
        "oof": {...},
        "if_mean": {...},
        "if_per_fold": [...]
    }
}
```

### Impact

- `lizyml/core/types/fit_result.py` の新規実装。
- `lizyml/core/types/predict_result.py` の新規実装。
- `lizyml/core/types/artifacts.py` の新規実装。
- `lizyml/core/types.py` の re-export。
- `tests/test_core/test_contracts.py` のゴールデンテスト。

### Compatibility

- 新規実装につき既存コードへの破壊的影響なし。
- 将来フィールドを追加する場合は format_version を上げ、本 HISTORY.md に migration を記録する。

### Alternatives Considered

- pydantic モデルで FitResult を定義する → np.ndarray 等を含む大型 dataclass には dataclass が適切。pydantic は Config 層に限定する。
- 動的 dict で返す → 型安全性がなく、ゴールデンテストでスキーマを固定できないため却下。

### Acceptance Criteria

- `FitResult` / `PredictionResult` のフィールド名・型が定義通りであることをゴールデンテストで固定する。
- `metrics` の階層 `raw/oof`, `raw/if_mean`, `raw/if_per_fold` が必ず存在することを検証する。
- スキーマ変更時にゴールデンテストが意図的に落ちることを確認する（テスト自体の有効性の検証）。

---

## 2026-03-04: Persistence / Export フォーマット仕様の確定

- ID: `H-0003`
- Status: `accepted`
- Scope: `Artifacts | Export`
- Related: `BLUEPRINT.md §14, §15.4`

### Context

Phase 14 で `Model.export()` / `Model.load()` を実装する前に、保存フォーマット・`format_version` の意味・将来の破壊的変更に対する migration 方針を仕様として固定する。未確定のまま実装すると、フォーマット変更のたびに無方針の破壊的変更が発生する。

### Proposal

#### ディレクトリ構造

```
{path}/
  metadata.json          # format_version, lizyml_version, timestamp, config, metrics, run_id
  fit_result.pkl         # FitResult dataclass (joblib 圧縮)
  refit_model.pkl        # RefitResult dataclass (joblib 圧縮)
```

#### metadata.json スキーマ（v1）

```json
{
  "format_version": 1,
  "lizyml_version": "0.1.0",
  "python_version": "3.11.x",
  "timestamp": "2026-03-04T12:00:00",
  "run_id": "uuid4",
  "config": { ... },
  "metrics": { ... },
  "feature_names": ["feat_a", "feat_b"],
  "task": "regression"
}
```

#### format_version の取り扱い

- `format_version = 1` を初版とする。
- フィールドの追加はマイナー変更（後方互換）とし、format_version を上げない。
- フィールドの削除・型変更・意味変更は破壊的変更とし、format_version を上げる。
- ロード時に `format_version` が未知の場合は `DESERIALIZATION_FAILED` を返す。

#### セキュリティ方針

- `.pkl` ファイルは joblib で保存・復元。
- `Model.load()` のドキュメントに「信頼できる出所からのみロードすること」を明記する。
- `metadata.json` のバリデーション（format_version / task / feature_names）をロード時に必ず実行する。

### Impact

- `lizyml/persistence/exporter.py`: `export(model, path)` の新規実装。
- `lizyml/persistence/loader.py`: `load(path) -> Model` の新規実装。
- `lizyml/core/model.py`: `export()` / `load()` の NotImplementedError を実装に置き換え。
- `tests/test_persistence/test_persistence.py`: export → load → predict E2E テスト。

### Compatibility

- 新規実装につき既存コードへの破壊的影響なし。
- 将来 format_version を上げる場合は本 HISTORY.md に migration エントリを追記する。

### Alternatives Considered

- 単一 `.pkl` に全情報を保存 → metadata.json を分離しておくことで version 確認・human-readable なメタ参照が可能になるため分離を採用。
- ONNX や PMML 形式 → LizyML 固有の FitResult / Artifacts の完全な復元には向かないため却下（将来の軽量 export フォーマットとして追加検討）。

### Acceptance Criteria

- `model.export(path)` でディレクトリが生成され、`metadata.json` / `fit_result.pkl` / `refit_model.pkl` が存在する。
- `Model.load(path)` でロードし、`predict()` が元モデルと同じ結果を返す。
- `format_version` が未知の場合に `DESERIALIZATION_FAILED` が返る。
- `metadata.json` に必須フィールド不足の場合に `DESERIALIZATION_FAILED` が返る。

### Decision

- Date: `2026-03-04`
- Result: `accepted`
- Notes: Phase 14 の実装前提として受け入れ。`format_version=1` を初版とする。

### Migration

- `format_version=1` から `format_version=2` への移行が必要になった場合、`lizyml/persistence/migrations/v1_to_v2.py` を追加し、ロード時に自動マイグレーションを試みる（または明示的エラーで移行を促す）。

---

## 2026-03-04: 回帰メトリクス MAPE・Huber Loss の追加

- ID: `H-0004`
- Status: `accepted`
- Scope: `Metrics`
- Related: `BLUEPRINT.md §7`

### Context

Tutorial Notebook でよく使われる回帰メトリクス（MAPE・Huber Loss）が未実装のため、チュートリアルでの利用および実務での利用に制限がある。

### Proposal

- `lizyml/metrics/regression.py` に `MAPE`・`HuberLoss` クラスを追加する。
- `MAPE`: 分母がゼロの場合は `UNSUPPORTED_METRIC` エラーを返す。
- `HuberLoss`: `delta=1.0` をデフォルトとし、コンストラクタで設定可能にする。Config 文字列では `"huber"` で `delta=1.0` として登録する。
- `lizyml/metrics/registry.py` の `_TASK_METRICS["regression"]` に `"mape"`, `"huber"` を追加する。
- 既存メトリクスへの影響なし（追加のみ）。

### Impact

- `lizyml/metrics/regression.py`: MAPE・HuberLoss クラス追加。
- `lizyml/metrics/__init__.py`: エクスポート追加。
- `lizyml/metrics/registry.py`: `_TASK_METRICS["regression"]` 更新。
- `tests/metrics/test_regression_metrics.py`: 新規テストファイル。

### Compatibility

- 既存の `"rmse"`, `"mae"`, `"r2"`, `"rmsle"` への影響なし。
- `format_version` 変更不要。

### Alternatives Considered

- SMAPE（対称 MAPE）を代わりに実装する → MAPE の方が一般的なため MAPE を優先し、SMAPE は将来の拡張候補とする。

### Acceptance Criteria

- `evaluate(metrics=["mape", "huber"])` が回帰タスクで正常に動作する。
- MAPE: y_true にゼロが含まれる場合に `LizyMLError(UNSUPPORTED_METRIC)` が返る。
- HuberLoss: 誤差が delta 以下の場合に二乗損失、超える場合に線形損失となることをテストで確認する。

### Decision

- Date: `2026-03-04`
- Result: `accepted`
- Notes: Tutorial Notebook の要件として受け入れ。

---

## 2026-03-04: model.evaluate_table() の追加

- ID: `H-0005`
- Status: `accepted`
- Scope: `Evaluation | Public API`
- Related: `BLUEPRINT.md §4.1, §13.2`

### Context

Notebook で評価結果を確認する際、`evaluate()` が返す nested dict を手作業で DataFrame 化する必要があり、「ユーザーにコードを書かせない」思想に反する。

### Proposal

- `Model.evaluate_table()` を追加し、`evaluate()` の dict を `pd.DataFrame` に整形して返す。
- 行 = メトリクス名、列 = `oof`, `if_mean`, `fold_0`...`fold_N-1`。calibrated がある場合は `cal_oof` 列を追加。
- ロジックは `lizyml/evaluation/table_formatter.py` に配置（Model にロジックを置かない原則を遵守）。

### Impact

- `lizyml/evaluation/table_formatter.py`: 新規。
- `lizyml/core/model.py`: `evaluate_table()` メソッド追加。
- `tests/test_evaluation/test_table_formatter.py`: 新規テスト。

### Compatibility

- FitResult / PredictionResult / Artifacts / format_version 変更なし。非破壊的追加。

### Alternatives Considered

- `evaluate()` の返り値自体を DataFrame にする → 既存契約の破壊になるため却下。

### Acceptance Criteria

- `model.evaluate_table()` が fit 後に DataFrame を返す。
- 行 = メトリクス名、列に oof / if_mean / fold 別が含まれる。
- calibrated 有りの場合 cal_oof 列が追加される。
- fit 前に呼ぶと MODEL_NOT_FIT。

### Decision

- Date: `2026-03-04`
- Result: `accepted`
- Notes: Notebook の UX 改善として受け入れ。

---

## 2026-03-04: model.residuals() / model.residuals_plot() の追加

- ID: `H-0006`
- Status: `accepted`
- Scope: `Public API | Plots`
- Related: `BLUEPRINT.md §4.1, §13.3`

### Context

BLUEPRINT §4.1 で `residuals()` / `residuals_plot()` が計画されていたが未実装。回帰タスクの残差分析はモデル診断の基本であり、Notebook でワンコールで可視化できる必要がある。

### Proposal

- `Model.residuals()`: 回帰タスク専用。`y - oof_pred` を `np.ndarray` で返す。
- `Model.residuals_plot()`: ヒストグラム + QQ plot の 2 パネルを Plotly で表示。
- `fit()` 中に `self._y` を一時保持（export/persistence には含めない）。
- `Model.load()` 後は y が不在のため呼び出し不可（MODEL_NOT_FIT エラー）。
- binary/multiclass では `UNSUPPORTED_TASK` を返す。
- プロット実装は `lizyml/plots/residuals.py` に配置。

### Impact

- `lizyml/core/model.py`: `_y` フィールド追加、`residuals()` / `residuals_plot()` メソッド追加。
- `lizyml/plots/residuals.py`: 新規。
- `tests/test_plots/test_residuals.py`: 新規テスト。

### Compatibility

- FitResult / format_version 変更なし。`_y` は Model の一時状態であり Artifacts に含めない。

### Alternatives Considered

- FitResult に y_true を保存する → Artifacts 契約の変更になるため却下。y はユーザーデータであり、モデル成果物ではない。
- load 後も利用可能にするため y を export に含める → データ漏洩リスクがあるため却下。

### Acceptance Criteria

- `model.residuals()` が回帰タスクで `(n_samples,)` の ndarray を返す。
- `model.residuals_plot()` が Plotly Figure を返す（ヒストグラム + QQ plot）。
- binary/multiclass で UNSUPPORTED_TASK。
- load 後に呼ぶと MODEL_NOT_FIT。

### Decision

- Date: `2026-03-04`
- Result: `accepted`
- Notes: 回帰タスクの基本診断機能として受け入れ。

---

## 2026-03-04: model.importance(kind="shap") / model.importance_plot(kind="shap") の追加

- ID: `H-0007`
- Status: `accepted`
- Scope: `Public API | Explain`
- Related: `BLUEPRINT.md §4.1, §14.1`

### Context

BLUEPRINT §4.1 で `importance(kind="shap")` が計画されていたが未実装。SHAP ベースの特徴量重要度は split/gain よりモデル非依存な指標であり、Notebook でワンコールで可視化できる必要がある。

### Proposal

- `Model.importance(kind="shap")`: fold ごとの validation データで SHAP を計算し、mean(|SHAP|) を fold 平均して `dict[str, float]` で返す。
- `Model.importance_plot(kind="shap")`: 上記 dict を Plotly 横棒グラフで表示。
- `fit()` 中に `self._X` を一時保持（export/persistence には含めない）。
- `Model.load()` 後は X が不在のため呼び出し不可（MODEL_NOT_FIT エラー）。
- `compute_shap_importance()` を `lizyml/explain/shap_explainer.py` に追加。
- `plot_importance_from_dict()` を `lizyml/plots/importance.py` に追加。
- shap は optional dependency（既存パターン踏襲）。

### Impact

- `lizyml/core/model.py`: `_X` フィールド追加、`importance()` / `importance_plot()` の kind="shap" 対応。
- `lizyml/explain/shap_explainer.py`: `compute_shap_importance()` 追加。
- `lizyml/plots/importance.py`: `plot_importance_from_dict()` 追加。
- `tests/test_explain/`: SHAP importance テスト追加。

### Compatibility

- FitResult / format_version 変更なし。`_X` は Model の一時状態。

### Alternatives Considered

- refit モデル + 全データで SHAP を計算 → CV の fold 構造を無視するため却下。fold 別 validation データで計算する方が CV philosophy に整合する。

### Acceptance Criteria

- `model.importance(kind="shap")` が `dict[str, float]` を返し、全 feature を含む。
- `model.importance_plot(kind="shap")` が Plotly Figure を返す。
- load 後に呼ぶと MODEL_NOT_FIT。
- shap 未インストール時に OPTIONAL_DEP_MISSING。

### Decision

- Date: `2026-03-04`
- Result: `accepted`
- Notes: SHAP 重要度の可視化機能として受け入れ。

---

## 2026-03-04: 全プロットの Plotly 移行

- ID: `H-0008`
- Status: `accepted`
- Scope: `Plots | Optional Dependency`
- Related: `BLUEPRINT.md §13.3`

### Context

matplotlib ベースのプロットは静的で Notebook 上での視認性・操作性に劣る。Plotly に移行することでインタラクティブなプロットを提供し、UX を向上させる。

### Proposal

- `pyproject.toml` の optional dependency `plots` グループを `matplotlib>=3.7` → `plotly>=5.0` に変更。
- `dependency-groups` (dev) も同様に変更。
- 既存 3 ファイル（`importance.py`, `learning_curve.py`, `oof_distribution.py`）を Plotly に書き換え。
- 新規ファイル（`residuals.py`）は最初から Plotly で実装。
- optional dep sentinel を `_mpl` → `_plotly` に変更。
- 返り値型を `matplotlib.figure.Figure` → `plotly.graph_objects.Figure` に変更。

### Impact

- `pyproject.toml`: optional dependency 変更。
- `lizyml/plots/importance.py`: Plotly 移行。
- `lizyml/plots/learning_curve.py`: Plotly 移行。
- `lizyml/plots/oof_distribution.py`: Plotly 移行。
- `tests/test_plots/test_plots.py`: Plotly Figure アサーションに更新。
- mypy overrides: `matplotlib.*` → `plotly.*`。

### Compatibility

- plot メソッドの返り値型が変わる破壊的変更。ただし plots は optional 機能であり、0.x バージョンのため許容する。

### Alternatives Considered

- デュアルサポート（matplotlib + plotly 両対応）→ 保守コストが倍増するため却下。
- 新機能のみ Plotly → ライブラリ内で可視化の一貫性が失われるため却下。

### Acceptance Criteria

- 全プロットメソッドが Plotly Figure を返す。
- plotly 未インストール時に OPTIONAL_DEP_MISSING。
- 既存テストが Plotly Figure アサーションで通過。

### Decision

- Date: `2026-03-04`
- Result: `accepted`
- Notes: UX 向上のため全面移行を受け入れ。

---

## 2026-03-04: residuals_plot() の拡張（散布図追加・kind 引数・IS/OOS 比較）

- ID: `H-0009`
- Status: `proposed`
- Scope: `Public API | Plots`
- Related: `BLUEPRINT.md §4.1, §13.3`

### Context

H-0006 で `residuals_plot()` を実装したが、以下の不足がある。

1. Actual vs Predicted 散布図が未実装。
2. 常に 2 パネル（histogram + QQ）が表示され、個別選択できない。
3. In-Sample（IF）と Out-of-Sample（OOF）の傾向比較ができない。

### Proposal

- `residuals_plot(kind=...)` に `kind` 引数を追加する。
  - `"scatter"`: Actual vs Predicted 散布図（x=predicted, y=actual）。IS と OOS を色分けオーバーレイ。y=x の完全予測参照線。
  - `"histogram"`: 残差ヒストグラム。IS と OOS を色分けオーバーレイ。mean/std アノテーション（OOS のみ）。
  - `"qq"`: QQ plot（OOS 残差のみ）。45 度参照線。
  - `"all"`: 上記 3 つを横並びサブプロットで表示（デフォルト）。
- 内部関数 `plot_residuals()` のシグネチャを変更し、`FitResult` + `y_true` を受け取る形式に統一する（他の plot 関数と同じパターン）。
- IS データは `fit_result.if_pred_per_fold[i]` + `fit_result.splits.outer[i][0]`（train_idx）から組み立てる。
- `kind` の値が不正な場合は `LizyMLError(INVALID_CONFIG)` を返す。

### Impact

- `lizyml/plots/residuals.py`: シグネチャ変更 + 3 プロット実装。
- `lizyml/core/model.py`: `residuals_plot(kind=...)` 引数追加。
- `tests/test_plots/test_residuals.py`: 新シグネチャ対応 + kind 別テスト追加。

### Compatibility

- `Model.residuals_plot()` のデフォルト `kind="all"` により、引数なし呼び出しは引き続き動作する。ただしパネル構成が 2 パネル（histogram + QQ）→ 3 パネル（scatter + histogram + QQ）に変わる。
- 内部関数 `plot_residuals()` のシグネチャは破壊的変更だが、内部 API のため影響は限定的。

### Alternatives Considered

- `residuals_plot()` とは別に `residuals_scatter()` を追加する → API が増えすぎるため却下。`importance_plot(kind=...)` と同じパターンに統一する。
- IS/OOS 比較を別メソッドにする → 同一グラフ上でのオーバーレイが最も直感的なため、`kind` で制御する方式を採用。

### Acceptance Criteria

- `model.residuals_plot(kind="scatter")` が Actual vs Predicted の Plotly Figure を返し、IS/OOS 両方のトレースと y=x 参照線を含む。
- `model.residuals_plot(kind="histogram")` が IS/OOS オーバーレイのヒストグラムを返す。
- `model.residuals_plot(kind="qq")` が QQ plot を返す。
- `model.residuals_plot(kind="all")` が 3 サブプロットの Figure を返す。
- `model.residuals_plot()` がデフォルトで `kind="all"` として動作する。
- 不正な kind 値で `INVALID_CONFIG` エラーが返る。

---

## 2026-03-04: EarlyStoppingConfig に validation_ratio エイリアス追加

- ID: `H-0010`
- Status: `proposed`
- Scope: `Config`
- Related: `BLUEPRINT.md §5.2, HISTORY.md H-0001`

### Context

現在の early stopping 設定は `early_stopping.inner_valid.ratio` で指定するが、ネストが深く冗長。`validation_ratio` エイリアスを追加して簡略化する。

### Proposal

- `EarlyStoppingConfig` に `validation_ratio: float | None = None` フィールドを追加する。
- `validation_ratio` 指定時、内部で `HoldoutInnerValidConfig(method="holdout", ratio=validation_ratio)` を自動生成する。
- `inner_valid` と `validation_ratio` の両方を指定した場合はバリデーションエラー。
- 既存の `inner_valid` 指定は引き続き動作する（後方互換）。

Config 例（新しい簡略記法）:
```python
"early_stopping": {"enabled": True, "rounds": 50, "validation_ratio": 0.1}
```

### Impact

- `lizyml/config/schema.py`: `EarlyStoppingConfig` に `validation_ratio` フィールド + `model_validator` 追加。
- テスト: validation_ratio ショートハンド・競合エラー・後方互換のテスト追加。

### Compatibility

- 非破壊的追加。既存の `inner_valid` 指定は変更なく動作する。

### Alternatives Considered

- `inner_valid` を廃止して `validation_ratio` に完全置換 → 将来 `InnerKFoldValid` 等の拡張余地がなくなるため却下。エイリアスとして共存させる。

### Acceptance Criteria

- `validation_ratio=0.2` 指定で `inner_valid.ratio == 0.2` になる。
- `inner_valid` と `validation_ratio` の両方指定でバリデーションエラー。
- 既存の `inner_valid` 形式が引き続き動作する。

---

## 2026-03-04: evaluate_table() の列順変更

- ID: `H-0011`
- Status: `proposed`
- Scope: `Evaluation | Public API`
- Related: `BLUEPRINT.md §13.2, HISTORY.md H-0005`

### Context

現在の `evaluate_table()` の列順は `oof, if_mean, fold_0...fold_N-1, cal_oof` だが、実務では IF（学習時の性能）を先に確認し、次に OOF（汎化性能）を比較するフローが自然。列順を `if_mean, oof, fold_0...fold_N-1, cal_oof` に変更する。

### Proposal

- `lizyml/evaluation/table_formatter.py` の `format_metrics_table()` で列の挿入順を `if_mean` → `oof` → `fold_0...fold_N-1` → `cal_oof` に変更する。

### Impact

- `lizyml/evaluation/table_formatter.py`: 列構築順の変更。
- `tests/test_evaluation/test_table_formatter.py`: 列順アサーションの更新。
- `BLUEPRINT.md §13.2`: 仕様記載の列順更新。

### Compatibility

- `evaluate_table()` の返り値は `pd.DataFrame` であり、列名でアクセスする限り影響なし。列の「位置」に依存するコードのみ影響する（通常ない）。

### Alternatives Considered

- 列順をユーザーが Config で指定できるようにする → 過剰な柔軟性のため却下。固定列順で十分。

### Acceptance Criteria

- `evaluate_table()` の列順が `if_mean, oof, fold_0...fold_N-1, cal_oof` になる。
- 既存テストが新しい列順で通過する。

---

## 2026-03-04: residuals_plot() の IS/OOS サンプル数バランシング

- ID: `H-0012`
- Status: `proposed`
- Scope: `Plots`
- Related: `BLUEPRINT.md §13.3, HISTORY.md H-0009`

### Context

K-fold CV（例: 5-fold）では IS サンプル数が OOS の約 4 倍になる。`residuals_plot(kind="scatter")` や `kind="histogram"` で IS/OOS を重ね描きすると、IS の点がOOS を覆い隠してグラフが見にくくなる。

### Proposal

- `lizyml/plots/residuals.py` 内部の IS データ描画時に、IS サンプル数が OOS サンプル数を超える場合、ランダムサンプリングで OOS と同数に間引く。
- サンプリングは `np.random.default_rng(seed=0)` で再現可能にする。
- バランシングは scatter と histogram の両方に適用する（QQ は OOS のみなので対象外）。
- 実装は `_build_is_data()` ヘルパーの後段、描画直前で行う（`_downsample_is()` ヘルパーを新設）。

### Impact

- `lizyml/plots/residuals.py`: `_downsample_is()` ヘルパー追加。`_add_scatter_traces()` / `_add_histogram_traces()` 呼び出し前に適用。

### Compatibility

- 既存テストの IS/OOS トレース存在チェックは変更不要（ダウンサンプリング後も IS トレースは描画される）。

### Alternatives Considered

- ユーザーに `max_is_samples` パラメータを公開する → 過剰な柔軟性のため却下。内部で OOS 数に合わせる方式で十分。
- opacity のみで対応する → サンプル数が大きく異なる場合は opacity だけでは不十分。

### Acceptance Criteria

- IS サンプル数 > OOS サンプル数の場合、IS が OOS と同数にダウンサンプリングされる。
- IS サンプル数 <= OOS サンプル数の場合、ダウンサンプリングは行われない。
- ダウンサンプリングは seed=0 で再現可能。

---

## 2026-03-05: Binary/Multiclass で StratifiedKFold をデフォルト化 + KFold 警告

- ID: `H-0013`
- Status: `implemented`
- Scope: `Config | Split`
- Related: `BLUEPRINT.md §5.2, §10.2`

### Context

現在、全タスク（regression/binary/multiclass）で `kfold` がデフォルトの split method。分類タスクではクラス比率を保持する `stratified_kfold` がベストプラクティスであり、ユーザーが明示指定を忘れると不均衡な fold 分割が発生する。

### Proposal

- Config loader の正規化で、`task` が `binary` または `multiclass` かつ `split.method` が未指定の場合、`stratified_kfold` をデフォルトにする。
- ユーザーが分類タスクで `method: "kfold"` を明示指定した場合、`warnings.warn()` で「StratifiedKFold の使用を推奨する」旨の警告を出す。
- 回帰タスクの挙動は変更しない（`kfold` のまま）。

### Impact

- `lizyml/config/loader.py`: 正規化ロジック追加。
- `lizyml/core/model.py`: `_build_splitter()` で `task` を参照してデフォルト判定。
- BLUEPRINT §5.2 の Config 例、§10.2 の Outer CV リストに注記追加。

### Compatibility

- 既存の `method: "kfold"` 明示指定は引き続き動作する（警告付き）。
- `method` 未指定で分類タスクを使っていたユーザーは、暗黙的に `stratified_kfold` に切り替わる（split indices が変わる）。

### Alternatives Considered

- `method` 未指定時はエラーにする → 既存ユーザーの breaking change になるため却下。
- 警告なしでデフォルトを変えるだけ → KFold を意図的に選んだユーザーへの情報がないため却下。

### Acceptance Criteria

- `task="binary"` かつ `split.method` 未指定 → StratifiedKFold が使われる。
- `task="binary"` かつ `split.method="kfold"` → 警告が出る + KFold が使われる。
- `task="regression"` かつ `split.method` 未指定 → KFold が使われる（変更なし）。
- `task="multiclass"` でも同様に StratifiedKFold がデフォルト。

---

## 2026-03-05: Precision at K メトリクス追加

- ID: `H-0014`
- Status: `implemented`
- Scope: `Metrics`
- Related: `BLUEPRINT.md §13.1`

### Context

Binary 分類で「上位 K% をポジティブと予測したときの精度」を評価する `Precision at K` は、不均衡データでのモデル評価に有用。現在未登録。

### Proposal

- `lizyml/metrics/classification.py` に `PrecisionAtKMetric` を追加する。
  - 名前: `precision_at_k`
  - `needs_proba: True`（確率ベースで上位 K% を算出）
  - `greater_is_better: True`
  - `supports_task: ["binary"]`
  - デフォルト `k=10`（上位 10%）。`k` はメトリクス設定で指定可能。
- `TASK_METRICS["binary"]` に登録する。

### Impact

- `lizyml/metrics/classification.py`: クラス追加。
- `lizyml/metrics/registry.py`: TASK_METRICS 更新。

### Compatibility

- 新規追加のみ。既存メトリクスの挙動は変更しない。

### Alternatives Considered

- `k` を固定値（10%）のみにする → 柔軟性が低いため、パラメータ化を採用。
- `Recall at K` も同時追加する → スコープを最小限にするため今回は見送り。

### Acceptance Criteria

- `precision_at_k` が `evaluate()` の結果に含まれる（binary タスク）。
- `k` パラメータで上位 K% のカットオフを変更できる。
- regression/multiclass タスクで指定した場合、`UNSUPPORTED_METRIC` エラー。

---

## 2026-03-05: ROC Curve プロット追加（IS/OOS 対応）

- ID: `H-0015`
- Status: `implemented`
- Scope: `Plots | Public API`
- Related: `BLUEPRINT.md §13.3`

### Context

Binary 分類の ROC Curve は基本的な評価可視化であり、BLUEPRINT §13.3 で「未実装」として明記されている。IS（In-Sample）と OOS（Out-of-Sample）の比較は過学習の判定に有用。

### Proposal

- `lizyml/plots/classification.py` を新規作成する。
- `plot_roc_curve(fit_result, y_true)` を追加する。
  - IS/OOS 両方の ROC Curve を重ね描きする。
  - IS: `if_pred_per_fold` + `splits.outer` の train_idx から算出。
  - OOS: `oof_pred` から算出。
  - AUC 値を凡例に表示する。
  - Plotly Figure を返す。
- `Model.roc_curve_plot()` を Facade メソッドとして追加する。

### Impact

- `lizyml/plots/classification.py`: 新規ファイル。
- `lizyml/plots/__init__.py`: export 追加。
- `lizyml/core/model.py`: `roc_curve_plot()` メソッド追加。

### Compatibility

- 新規追加のみ。既存 API に変更なし。

### Alternatives Considered

- fold ごとの ROC を個別に描画する → 煩雑になるため、IS/OOS 集約の 2 本線を採用。
- PR Curve も同時追加する → スコープを最小限にするため今回は見送り。

### Acceptance Criteria

- `model.roc_curve_plot()` が Plotly Figure を返す。
- IS と OOS の 2 本の ROC Curve が描画される。
- AUC 値が凡例に表示される。
- binary タスク以外で呼び出した場合は `LizyMLError` を返す。
- `y_true` は `fit()` 時に一時保持した値を使用する（`residuals_plot` と同じパターン）。

---

## 2026-03-05: Confusion Matrix テーブル追加（IS/OOS 対応）

- ID: `H-0016`
- Status: `implemented`
- Scope: `Evaluation | Public API`
- Related: `BLUEPRINT.md §13.3`

### Context

Binary/Multiclass 分類の Confusion Matrix はモデル評価の基本。BLUEPRINT §13.3 で「未実装」として明記されている。IS/OOS の比較でモデルの過学習を判定したい。出力は可視化（プロット）ではなくテーブル（DataFrame）とする。

### Proposal

- `lizyml/evaluation/confusion.py` を新規作成する。
- `confusion_matrix_table(fit_result, y_true, *, threshold=0.5) -> dict[str, pd.DataFrame]` を追加する。
  - 戻り値: `{"is": pd.DataFrame, "oos": pd.DataFrame}`
  - DataFrame は sklearn の `confusion_matrix` 相当の行列形式。
  - IS: `if_pred_per_fold` + `splits.outer` の train_idx から集約。
  - OOS: `oof_pred` から算出。
  - binary: `threshold` で確率→クラスラベル変換。
  - multiclass: argmax でクラスラベル変換。
- `Model.confusion_matrix()` を Facade メソッドとして追加する。

### Impact

- `lizyml/evaluation/confusion.py`: 新規ファイル。
- `lizyml/core/model.py`: `confusion_matrix()` メソッド追加。

### Compatibility

- 新規追加のみ。既存 API に変更なし。

### Alternatives Considered

- Plotly ヒートマップで可視化する → ユーザー要件がテーブル出力のため、DataFrame を採用。
- IS/OOS を 1 つの DataFrame にまとめる → 可読性が落ちるため dict で分離。

### Acceptance Criteria

- `model.confusion_matrix()` が `{"is": DataFrame, "oos": DataFrame}` を返す。
- binary タスクで `threshold` パラメータが機能する。
- multiclass タスクでも動作する。
- regression タスクで呼び出した場合は `LizyMLError` を返す。

---

## 2026-03-05: Calibration Curve + Predicted Probability Histogram 追加

- ID: `H-0017`
- Status: `implemented`
- Scope: `Plots | Public API`
- Related: `BLUEPRINT.md §12.3, §13.3`

### Context

Binary 分類の Calibration 有効時に、校正の効果を可視化する手段がない。BLUEPRINT §13.3 で「reliability diagram / ECE」として計画されている。Calibration Curve（Reliability Diagram）で校正精度を確認し、Predicted Probability Histogram で Raw/Calibrated の分布変化を比較したい。

### Proposal

- `lizyml/plots/calibration.py` を新規作成する。
- `plot_calibration_curve(fit_result, y_true) -> plotly.graph_objects.Figure` を追加する。
  - Raw OOF（`fit_result.oof_pred`）と Calibrated OOF（`fit_result.calibrator.calibrated_oof`）の 2 本の Reliability Diagram を描画。
  - 理想線（y=x）を参照線として描画。
  - bin 数はデフォルト 10（`sklearn.calibration.calibration_curve` 相当）。
- `plot_probability_histogram(fit_result) -> plotly.graph_objects.Figure` を追加する。
  - Raw OOF と Calibrated OOF の確率分布ヒストグラムを重ね描き。
  - 校正前後の分布シフトを視覚的に確認できるようにする。
- `Model.calibration_plot()` および `Model.probability_histogram_plot()` を Facade メソッドとして追加する。

### Impact

- `lizyml/plots/calibration.py`: 新規ファイル。
- `lizyml/plots/__init__.py`: export 追加。
- `lizyml/core/model.py`: 2 メソッド追加。

### Compatibility

- 新規追加のみ。既存 API に変更なし。

### Alternatives Considered

- Calibration Curve と Histogram を 1 つの Figure にサブプロットで統合する → 個別に使いたいケースがあるため、別関数を採用。
- ECE 値もプロットに埋め込む → 将来追加可能だが、初期実装はシンプルに保つ。

### Acceptance Criteria

- `model.calibration_plot()` が Plotly Figure を返す。
- Raw と Calibrated の 2 本の Reliability Diagram + 理想線が描画される。
- `model.probability_histogram_plot()` が Plotly Figure を返す。
- Raw と Calibrated の 2 つのヒストグラムが重ね描きされる。
- Calibration 未有効時に呼び出した場合は `LizyMLError` を返す。
- binary タスク以外で呼び出した場合は `LizyMLError` を返す。
- データソースは OOF（cross-fit 由来の `calibrated_oof`）であり、`c_final` は使用しない。

---

## 2026-03-05: Multiclass メトリクス拡張（AUC OvR / Average Precision OvR / Brier OvR）

- ID: `H-0018`
- Status: `implemented`
- Scope: `Metrics | Public API`
- Related: `BLUEPRINT.md §13.1`

### Context

Multiclass 分類タスクの `TASK_METRICS["multiclass"]` は現在 `logloss / f1 / accuracy` の 3 種のみ。AUC（OvR）、Average Precision（OvR）、Brier（OvR）は multiclass でも One-vs-Rest 展開で計算可能であり、Binary Notebook と対称的な評価を行うために必要。

### Proposal

既存の `AUCMetric` / `AUCPRMetric` / `BrierMetric` を multiclass 対応に拡張し、`TASK_METRICS["multiclass"]` に登録する。

- **AUC（OvR）**: `y_pred` が 2D `(n_samples, n_classes)` の場合、`roc_auc_score(y_true, y_pred, multi_class='ovr', average='macro')` を呼ぶ。
- **Average Precision（OvR）**: `y_true` を One-Hot 展開し、クラスごとに `average_precision_score` を計算して macro 平均。
- **Brier（OvR）**: `y_true` を One-Hot 展開し、クラスごとに `brier_score_loss` を計算して macro 平均。
- 各メトリクスの `__call__` で `y_pred.ndim` を分岐条件とし、1D（binary）はそのまま、2D（multiclass）は OvR ロジックに分岐する。
- `_require_1d_same_len` ガードは multiclass 経路ではスキップする（2D は長さ比較で `y_pred.shape[0] == len(y_true)` を使う）。

### Impact

- `lizyml/metrics/classification.py`: `AUCMetric.__call__` / `AUCPRMetric.__call__` / `BrierMetric.__call__` に multiclass 分岐を追加。
- `lizyml/metrics/registry.py`: `TASK_METRICS["multiclass"]` に `auc`, `auc_pr`, `brier` を追加。
- `lizyml/metrics/classification.py`: `supports_task` に `"multiclass"` を追加（各クラス）。

### Compatibility

- 既存の binary 経路は変更なし（`y_pred.ndim == 1` の場合は従来ロジック）。
- multiclass で新たにこれらメトリクスが利用可能になる（追加のみ）。

### Alternatives Considered

- 別名メトリクス（`auc_ovr` / `brier_ovr`）として新規追加する → メトリクス名が増え Config が煩雑になるため、同名で multiclass 対応する方式を採用。
- `weighted` 平均をデフォルトにする → `macro` の方が class imbalance に対して公平な評価のため、`macro` を採用。

### Acceptance Criteria

- `task="multiclass"` で `evaluate(metrics=["auc", "auc_pr", "brier"])` が値を返す。
- multiclass AUC は `roc_auc_score(..., multi_class='ovr', average='macro')` と一致する。
- multiclass Average Precision はクラスごとの `average_precision_score` の macro 平均と一致する。
- multiclass Brier はクラスごとの `brier_score_loss` の macro 平均と一致する。
- binary タスクの既存動作が変わらない。
- regression タスクで指定した場合は `UNSUPPORTED_METRIC` エラー。

---

## 2026-03-05: ROC Curve の Multiclass OvR 拡張

- ID: `H-0019`
- Status: `implemented`
- Scope: `Plots | Public API`
- Related: `BLUEPRINT.md §13.3, HISTORY.md H-0015`

### Context

H-0015 で提案した ROC Curve プロットは binary 限定。Multiclass 分類では One-vs-Rest（OvR）方式でクラスごとの ROC Curve を描画するのが標準的な手法。Binary Notebook と対称的な可視化を Multiclass Notebook でも提供したい。

### Proposal

H-0015 の `plot_roc_curve(fit_result, y_true)` を multiclass 対応に拡張する。

- `task="multiclass"` の場合、クラスごとに OvR の ROC Curve を描画する。
  - IS: `if_pred_per_fold`（2D）+ `splits.outer` の train_idx から集約し、クラスごとの OvR を算出。
  - OOS: `oof_pred`（2D）からクラスごとの OvR を算出。
- レイアウト: IS と OOS を Plotly subplots で横並びにし、各 subplot にクラスごとの ROC 曲線を描画する。
- 各クラスの AUC 値を凡例に表示する。
- macro 平均 AUC もタイトルまたは凡例に表示する。
- `task="binary"` の場合は H-0015 の従来動作（IS/OOS の 2 本）を維持する。

### Impact

- `lizyml/plots/classification.py`: `plot_roc_curve` の multiclass 分岐を追加。
- H-0015 の binary 実装と同一関数内で分岐する。

### Compatibility

- binary の既存動作は変更なし。
- multiclass は新規追加のみ。

### Alternatives Considered

- binary と multiclass で関数を分ける（`plot_roc_curve_ovr`）→ Facade API が増えるため、同一関数で task 分岐する方式を採用。
- micro 平均の ROC も描画する → 初期実装はシンプルに保ち、macro 平均 + クラス別のみ。

### Acceptance Criteria

- `task="multiclass"` で `model.roc_curve_plot()` が Plotly Figure を返す。
- IS と OOS の 2 つの subplot にクラスごとの OvR ROC Curve が描画される。
- 各クラスの AUC 値が凡例に表示される。
- macro 平均 AUC が表示される。
- `task="binary"` では H-0015 の従来動作が維持される。
- `task="regression"` で呼び出した場合は `LizyMLError` を返す。

---

## 2026-03-05: InnerValid の split method 設定対応（stratified / group / time-aware holdout）

- ID: `H-0020`
- Status: `implemented`
- Scope: `Config | Training | Split`
- Related: `BLUEPRINT.md §5.2, §10.3`

### Context

現在の `EarlyStoppingConfig.inner_valid` は `HoldoutInnerValidConfig(method="holdout")` のみで、ランダム分割しかサポートしない。`HoldoutInnerValid.split()` は `y` と `groups` を引数に受け取るが無視しており、stratified / group / time-aware な内側分割ができない。

BLUEPRINT §10.3 では `HoldoutInnerValid(ratio, stratify, group, time, random_state)` が計画されているが未実装。分類タスクで Stratified、group_col がある場合に group-aware、time_col がある場合に time-aware な inner split が必要。

### Proposal

#### Config 変更

`HoldoutInnerValidConfig` に `stratify` パラメータを追加し、`InnerValidConfig` を discriminated union に拡張する。

```python
class HoldoutInnerValidConfig(BaseModel):
    method: Literal["holdout"]
    ratio: float = 0.1
    stratify: bool = False  # 新規追加
    random_state: int = 42

class GroupHoldoutInnerValidConfig(BaseModel):
    method: Literal["group_holdout"]
    ratio: float = 0.1
    random_state: int = 42

class TimeHoldoutInnerValidConfig(BaseModel):
    method: Literal["time_holdout"]
    ratio: float = 0.1

InnerValidConfig = HoldoutInnerValidConfig | GroupHoldoutInnerValidConfig | TimeHoldoutInnerValidConfig
```

`EarlyStoppingConfig.inner_valid` の型を `InnerValidConfig | None` に変更する。

#### デフォルト解決ルール

`inner_valid` が未指定（`None`）かつ `enabled=True` の場合、`Model.fit()` 時に外側 CV の method に応じて自動解決する。

| 外側 split.method | inner_valid のデフォルト |
|---|---|
| `stratified_kfold` | `holdout(stratify=True)` |
| `group_kfold` | `group_holdout` |
| `time_series` | `time_holdout` |
| `kfold`（またはCV未使用） | `holdout(stratify=False)` |

この解決は Config loader ではなく `Model._build_inner_valid()` で行う（外側 split の情報が必要なため）。

#### InnerValid 実装

- `HoldoutInnerValid`: `stratify=True` の場合、`sklearn.model_selection.StratifiedShuffleSplit(n_splits=1, test_size=ratio)` を使い `y` に基づく層化抽出を行う。`stratify=False` は現行のランダム分割を維持。
- `GroupHoldoutInnerValid`: `groups` をユニークグループ単位で分割する。validation には末尾グループを使用し、group overlap を防ぐ。
- `TimeHoldoutInnerValid`: 時系列順を維持し、末尾 `ratio` 割合を validation に割り当てる（shuffle なし）。BLUEPRINT §10.3 の「時系列は内側も時系列順を厳守」に準拠。

#### CVTrainer の変更

`cv_trainer.py` で `inner_valid.split()` に `y` と `groups` を適切に渡す。現在すでに `y=y_train.to_numpy()` を渡しているが、`groups` は渡していないため追加する。

### Impact

- `lizyml/config/schema.py`: `InnerValidConfig` discriminated union、`GroupHoldoutInnerValidConfig`、`TimeHoldoutInnerValidConfig` 追加。`HoldoutInnerValidConfig` に `stratify` フィールド追加。
- `lizyml/training/inner_valid.py`: `StratifiedHoldoutInnerValid`（または `HoldoutInnerValid` に stratify 分岐追加）、`GroupHoldoutInnerValid`、`TimeHoldoutInnerValid` 追加。
- `lizyml/core/model.py`: `_build_inner_valid()` にデフォルト解決ロジック追加。
- `lizyml/training/cv_trainer.py`: `inner_valid.split()` 呼び出しに `groups` を渡す。

### Compatibility

- 既存の `inner_valid: {method: "holdout", ratio: 0.1}` は動作が変わらない（`stratify` のデフォルトは `False`）。
- `validation_ratio` ショートハンドも引き続き動作する（デフォルト解決で自動判定）。
- `inner_valid` 未指定のデフォルト挙動が変わる: 現在は常にランダム holdout → 今後は外側 CV 方式に追従。ただし `kfold` の場合はランダム holdout のままで既存挙動と一致。

### Alternatives Considered

- Config loader で外側 split.method を参照してデフォルトを解決する → loader 時点では `task` 情報しかなく `split` と `inner_valid` の関連性を解決できないため、`_build_inner_valid()` での解決を採用。
- `inner_valid.method` を外側と完全同名にする（`stratified_kfold` など）→ 内側は常に 1 分割の holdout であり KFold ではないため、名前の混乱を避けて `holdout` / `group_holdout` / `time_holdout` を採用。

### Acceptance Criteria

- `split.method="stratified_kfold"` かつ `inner_valid` 未指定 → inner split が stratified holdout になる。
- `split.method="group_kfold"` かつ `inner_valid` 未指定 → inner split が group holdout になる（group overlap なし）。
- `split.method="time_series"` かつ `inner_valid` 未指定 → inner split が time holdout になる（末尾を validation、shuffle なし）。
- `split.method="kfold"` かつ `inner_valid` 未指定 → inner split がランダム holdout になる（既存挙動維持）。
- `inner_valid` を明示指定した場合は外側 split.method に関わらずその設定が優先される。
- `validation_ratio` ショートハンドが引き続き動作する。
- `time_holdout` で shuffle が行われないことをテストで検証する。
- `group_holdout` で group overlap が発生しないことをテストで検証する。

---

## 2026-03-05: LGBMConfig スマートパラメーター追加（auto_num_leaves / ratio パラメーター / feature_weights / balanced）

- ID: `H-0021`
- Status: `implemented`
- Scope: `Config | EstimatorAdapter`
- Decision Date: 2026-03-05
- Related: `BLUEPRINT.md §5.3, §14.2`

### Context

現在の `LGBMConfig.params` は `dict[str, Any]` の生パラメーターのみで、データサイズやタスクに依存するパラメーターをユーザーが手動計算する必要がある。Config の簡潔さを損ない、設定ミスの原因になる。

### Proposal

`LGBMConfig` に以下のスマートパラメーターフィールドを追加し、`fit()` 時に学習データの情報に基づいて LightGBM ネイティブパラメーターに解決する。

#### 1. auto_num_leaves（葉の数の自動算出）

- `auto_num_leaves: bool = True`
- `num_leaves_ratio: float = 1.0`（`0 < ratio ≤ 1`）
- 算出ロジック:
  - `params.max_depth` が未指定または負値（制限なし）→ 基準値 = `131072`
  - `params.max_depth` が指定されている → 基準値 = `2 ^ max_depth`
  - `num_leaves = clamp(ceil(基準値 × num_leaves_ratio), 8, 131072)`
- 制約: `auto_num_leaves=True` 時に `params.num_leaves` を直接指定した場合は `CONFIG_INVALID`。

#### 2. データサイズ相対比率パラメーター

学習データの行数に対する割合で指定し、fit 時に絶対値に変換する。

- `min_data_in_leaf_ratio: float | None = None`（`0 < ratio < 1`）→ `min_data_in_leaf = max(1, ceil(n_rows × ratio))`
- `min_data_in_bin_ratio: float | None = None`（`0 < ratio < 1`）→ `min_data_in_bin = max(1, ceil(n_rows × ratio))`
- 制約: ratio 指定と対応する絶対値パラメーター（`params.min_data_in_leaf` 等）の同時指定は `CONFIG_INVALID`。

#### 3. feature_weights（特徴量重みの辞書指定）

- `feature_weights: dict[str, float] | None = None`
- 未指定特徴量は `1.0` で自動補完。
- 学習データの特徴量順に並び替えたリストに変換し、LightGBM に渡す。
- 副作用: `feature_pre_filter = False` を強制する。
- 制約: 重み `> 0` 必須。学習データに存在しない未知の特徴量名は `CONFIG_INVALID`。

#### 4. balanced（クラス重み自動均衡化）

- `balanced: bool = False`
- `True` 時、学習データのクラス比率から自動的に重みを算出する。
  - binary: `scale_pos_weight = neg_count / pos_count` を設定。
  - multiclass: `sample_weight` でクラス逆頻度重み付け。
  - regression: `UNSUPPORTED_TASK` エラー。

### Impact

- `lizyml/config/schema.py`: `LGBMConfig` に 6 フィールド追加 + `model_validator` でバリデーション。
- `lizyml/estimators/lgbm.py`: `resolve_smart_params(n_rows, feature_names, y)` — fit 時にスマートパラメーターを LightGBM ネイティブパラメーターに解決するロジック追加。
- `lizyml/core/model.py`: `fit()` で `n_rows` / `feature_names` / `y` を解決関数に渡す。

### Compatibility

- 既存の `LGBMConfig(params={...})` は影響なし（新フィールドはすべてデフォルト付き）。
- `auto_num_leaves` のデフォルトが `True` のため、`params.num_leaves` を直接指定しているユーザーは `auto_num_leaves=False` の追加が必要（バリデーションエラーで通知）。
- `format_version` 変更不要（Config の拡張のみ）。

### Alternatives Considered

- `TrainingConfig` に配置 → LightGBM 固有のため `LGBMConfig` が適切。将来 sklearn adapter 等で同様の概念があれば各 adapter config に追加する。
- `params` dict の中にネストする → pydantic バリデーションが効かないため却下。
- `num_leaves_ratio` を `num_leaves` の型を `int | float` にして判定する → 暗黙的で分かりにくいため、明示的な `auto_num_leaves` フラグを採用。

### Acceptance Criteria

- `auto_num_leaves=True`, `max_depth=5` → `num_leaves = ceil(32 × ratio)`, `clamp(8, 131072)` が適用される。
- `auto_num_leaves=True` + `params.num_leaves` 指定 → `CONFIG_INVALID`。
- `auto_num_leaves=False` + `params.num_leaves=64` → そのまま `64` が使われる。
- `min_data_in_leaf_ratio=0.01`, `n_rows=10000` → `min_data_in_leaf=100`。
- `min_data_in_leaf_ratio` + `params.min_data_in_leaf` 同時指定 → `CONFIG_INVALID`。
- `feature_weights={"a": 2.0}` + features=`[a, b, c]` → `[2.0, 1.0, 1.0]`, `feature_pre_filter=False`。
- `feature_weights={"unknown": 1.0}` → `CONFIG_INVALID`。
- `balanced=True`, binary → `scale_pos_weight` が正しく設定される。
- `balanced=True`, regression → `UNSUPPORTED_TASK`。

---

## 2026-03-05: LightGBM タスク別デフォルトパラメータープロファイル

- ID: `H-0022`
- Status: `implemented`
- Scope: `Config | EstimatorAdapter`
- Decision Date: 2026-03-05
- Related: `BLUEPRINT.md §14.3, §5.2`

### Context

現在 `LGBMAdapter._build_params()` は `objective` / `metric` / `verbose` / `random_state` のみをデフォルト設定し、`learning_rate` / `max_depth` 等は LightGBM ライブラリの内部デフォルトに依存している。実務で頻繁に使うパラメーターの推奨デフォルト値を明示的に設定し、ユーザーが最小限の Config でも妥当な精度のモデルを得られるようにする。

### Proposal

#### タスク別 objective / metric デフォルト

| | regression | binary | multiclass |
|---|---|---|---|
| objective | `huber` | `binary` | `multiclass` |
| metric | `[huber, mae, mape]` | `[auc, binary_logloss]` | `[auc_mu, multi_logloss]` |

注記:
- regression の objective を `regression`（L2）から `huber` に変更。外れ値に対してロバスト。
- `brier` は LightGBM ネイティブ未対応のため、binary metric デフォルトから除外。カスタム feval 対応は将来の拡張点とする。
- `precision_at_k` も LightGBM ネイティブ未対応。将来のカスタム feval 対応として保留。

#### 共通デフォルト

| パラメーター | デフォルト値 | 備考 |
|---|---|---|
| `boosting` | `gbdt` | |
| `first_metric_only` | `False` | |
| `n_estimators` | `1500` | sklearn API 相当の `num_boost_round` |
| `learning_rate` | `0.001` | 低学習率で early stopping に依存 |
| `max_depth` | `5` | |
| `max_bin` | `511` | |
| `feature_fraction` | `0.7` | |
| `bagging_fraction` | `0.7` | |
| `bagging_freq` | `10` | |
| `lambda_l1` | `0.0` | |
| `lambda_l2` | `0.000001` | |

#### Training デフォルト変更

| パラメーター | 現在のデフォルト | 新デフォルト |
|---|---|---|
| `early_stopping.enabled` | `False` | `True` |
| `early_stopping.rounds` | `50` | `150` |

`validation_ratio` のデフォルトは `0.1`（`EarlyStoppingConfig.validation_ratio` のデフォルトとして設定。`early_stopping.enabled=True` 時に `inner_valid` 未指定の場合に自動適用）。

### Impact

- `lizyml/estimators/lgbm.py`: `_build_params()` のデフォルト値拡張、`_TASK_OBJECTIVE` / `_TASK_METRIC` マッピング更新。
- `lizyml/config/schema.py`: `EarlyStoppingConfig` のデフォルト値変更（`enabled=True`, `rounds=150`, `validation_ratio=0.1`）。
- 既存テスト: seed 固定テスト・再現性テストの期待値が変わる可能性あり（デフォルト objective / パラメーター変更のため）。

### Compatibility

- `LGBMConfig.params` で明示指定した値はデフォルトを上書きするため、パラメーターを指定しているユーザーは影響なし。
- デフォルト値のみ使用しているユーザーは挙動が変わる（`0.x` バージョンのため許容）。
- regression の `objective` が `regression` → `huber` に変わるため、既存の回帰モデルの出力が変わる。
- `early_stopping.enabled` が `True` になるため、未指定ユーザーは early stopping が有効になる。

### Alternatives Considered

- デフォルト値を変更せず、推奨設定を Config テンプレートとしてドキュメントで提供 → ユーザーが毎回コピーする手間がかかるため却下。
- profile 方式（`"conservative"` / `"aggressive"` 等の名前付きプロファイル）→ 過度な抽象化のため却下。単一のバランスの取れたデフォルトを提供する。
- `huber` ではなく `regression`（L2）を維持し、外れ値対応はユーザー責任とする → 実務では外れ値がある場合が多く、`huber` の方がロバストなデフォルトとして適切。

### Acceptance Criteria

- Config 未指定時に `learning_rate=0.001`, `max_depth=5`, `max_bin=511` 等がデフォルト適用される。
- `params` で明示指定した値がデフォルトを上書きする。
- regression タスクで `objective=huber` がデフォルトになる。
- binary タスクで `metric=[auc, binary_logloss]` がデフォルトになる。
- multiclass タスクで `metric=[auc_mu, multi_logloss]` がデフォルトになる。
- `early_stopping.enabled` のデフォルトが `True` になる。
- `early_stopping.rounds` のデフォルトが `150` になる。
- `early_stopping.validation_ratio` のデフォルトが `0.1` になる。
- 既存テストがデフォルト変更に伴い適切に更新されている。

---

## 2026-03-05: TuningResult 型導入と tuning_table() API 追加

- ID: `H-0023`
- Status: `accepted`
- Scope: `Public API | Tuning`
- Related: `BLUEPRINT.md §6.1, §4.1, §7.1`

### Context

現在 `Tuner.tune()` は `dict(study.best_params)` のみを返し、Optuna Study オブジェクト（全 trial の探索履歴）を破棄している。`Model.tune()` も同様に `dict[str, Any]` を返す。

Tuning Notebook で「探索したパラメーターと各パラメーターでの評価」を一覧表示するには、全 trial の履歴が必要だが、現在の実装では取得手段がない。

### Proposal

#### 1. TuningResult 型の導入

`lizyml/core/types/tuning_result.py` に `TuningResult` dataclass を新設する。

```python
@dataclass(frozen=True)
class TrialResult:
    number: int               # trial 番号（0-indexed）
    params: dict[str, Any]    # 探索パラメーター
    score: float              # OOF メトリクス値
    state: str                # "complete" | "pruned" | "fail"

@dataclass(frozen=True)
class TuningResult:
    best_params: dict[str, Any]
    best_score: float
    trials: list[TrialResult]  # 全 trial 履歴（番号順）
    metric_name: str           # 最適化メトリクス名
    direction: str             # "minimize" | "maximize"
```

#### 2. Tuner.tune() の戻り値変更

`Tuner.tune()` の戻り値を `dict[str, Any]` → `TuningResult` に変更する。Optuna Study の `study.trials` から全 trial 情報を収集して `TuningResult` を構築する。

#### 3. Model.tune() の戻り値変更

`Model.tune()` の戻り値を `dict[str, Any]` → `TuningResult` に変更する。内部で `self._best_params = result.best_params` を維持し、`fit()` 連携は既存通り。`TuningResult` を `self._tuning_result` として保持する。

#### 4. Model.tuning_table() の追加

`Model.tuning_table() -> pd.DataFrame` を追加する。`TuningResult.trials` を DataFrame に変換する。

- 列: `trial`, `score`, + 各探索パラメーター名
- 行: trial 番号順
- `score` 列名は `TuningResult.metric_name` を使用する（例: `rmse`）
- `tune()` 未実行時は `MODEL_NOT_FIT` エラー

### Impact

- `lizyml/core/types/tuning_result.py`: 新規ファイル（`TuningResult`, `TrialResult`）。
- `lizyml/tuning/tuner.py`: `tune()` 戻り値を `TuningResult` に変更。
- `lizyml/core/model.py`: `tune()` 戻り値変更 + `tuning_table()` メソッド追加 + `_tuning_result` 保持。
- `tests/test_tuning/`: `tune()` の戻り値アサーション更新、`tuning_table()` テスト追加。

### Compatibility

- `tune()` の戻り値型が `dict` → `TuningResult` に変わる破壊的変更。ただし `0.x` バージョンのため許容。
- `TuningResult.best_params` で従来の dict アクセスパターンは維持可能。
- `fit()` 連携は内部で `best_params` を参照するため影響なし。

### Alternatives Considered

- `tune()` の戻り値は dict のまま、別途 `study` を保持して `tuning_table()` で変換する → API として `TuningResult` の方が明確で、study への依存を外部に漏らさない。
- Optuna の `study.trials_dataframe()` をそのまま返す → Optuna 依存が公開 API に漏れるため却下。自前で変換する。
- `tuning_table()` を `TuningResult` のメソッドにする → `Model` の Facade パターンに合わせ、`Model.tuning_table()` として提供する。

### Acceptance Criteria

- `model.tune()` が `TuningResult` を返す。
- `TuningResult.best_params` が `dict[str, Any]` で最良パラメーターを返す。
- `TuningResult.best_score` が最良スコアを返す。
- `TuningResult.trials` が全 trial の `TrialResult` リストを返す（番号順）。
- `model.tuning_table()` が `pd.DataFrame` を返す。
- DataFrame の列が `trial`, メトリクス名, 探索パラメーター名を含む。
- `tune()` 未実行時に `tuning_table()` を呼ぶと `MODEL_NOT_FIT` エラー。
- `fit()` が `tune()` 後に `best_params` を正しく使用する（既存動作維持）。

### Decision

- Date: `2026-03-05`
- Result: `accepted`
- Notes: `feat/phase-20-classification-enhancements` ブランチで実施。`TuningResult` / `TrialResult` を `lizyml/core/types/tuning_result.py` に追加。`Tuner.tune()` と `Model.tune()` の戻り値を `TuningResult` に変更。`Model.tuning_table()` メソッドを追加。

---

## 2026-03-05: デフォルト Tuning Space の導入（タスク別デフォルト探索空間 + Tuner 拡張）

- ID: `H-0024`
- Status: `accepted`
- Scope: `Config | Tuning | Public API`
- Related: `BLUEPRINT.md §11.1, §5.2, §14.3`

### Context

現在 `Model.tune()` は `tuning.optuna.space` が必須で、ユーザーが毎回 SearchSpace を手動定義する必要がある。実務では LightGBM のハイパーパラメーターの探索範囲はタスク種別によりほぼ定型化されており、デフォルトの探索空間を提供すればユーザーの手間を大幅に削減できる。

また、現在の Tuner は `LGBMConfig.params` の model パラメーターのみ探索可能で、スマートパラメーター（H-0021）や training パラメーター（`early_stopping_rounds` / `validation_ratio`）は trial 間で固定されている。これらも探索対象に含めることで、より効果的なハイパーパラメーター最適化が可能になる。

### Proposal

#### 1. デフォルト Tuning Space の定義

`tuning.optuna.space` が空（`{}`）の場合、タスク別のデフォルト探索空間を自動適用する。

##### 探索次元（SearchDim）

| パラメーター | 型 | 範囲 | カテゴリ | 備考 |
|---|---|---|---|---|
| `objective` | categorical | regression: `[huber, fair]`, binary: `[binary]`, multiclass: `[multiclass, multiclassova]` | model | タスク別選択肢 |
| `n_estimators` | int | `[600, 2500]` | model | `num_boost_round` 相当 |
| `learning_rate` | float (log) | `[0.0001, 0.1]` | model | 対数スケール |
| `max_depth` | int | `[3, 12]` | model | |
| `feature_fraction` | float | `[0.5, 1.0]` | model | |
| `bagging_fraction` | float | `[0.5, 1.0]` | model | |
| `num_leaves_ratio` | float | `[0.5, 1.0]` | smart | `auto_num_leaves=True` 前提 |
| `min_data_in_leaf_ratio` | float | `[0.01, 0.2]` | smart | データサイズ相対 |
| `early_stopping_rounds` | int | `[40, 240]` | training | `EarlyStoppingConfig.rounds` |
| `validation_ratio` | float | `[0.1, 0.3]` | training | `EarlyStoppingConfig.validation_ratio` |

##### 固定パラメーター（探索しない）

| パラメーター | 値 | 備考 |
|---|---|---|
| `auto_num_leaves` | `True` | `num_leaves_ratio` で間接制御 |
| `first_metric_only` | `True` | 早期停止の判定を主メトリクスのみにする |
| `metric` | regression: `[huber, mae, mape]`, binary: `[auc, binary_logloss]`, multiclass: `[auc_mu, multi_logloss]` | H-0022 のデフォルトと同一 |

注記:
- `brier` は LightGBM ネイティブ未対応のため Binary metric から除外。
- `precision_at_k` も LightGBM ネイティブ未対応のため除外。
- Binary の objective は `binary` のみ（選択肢が 1 つのため実質固定）。

##### 最適化メトリクスと方向

| タスク | `metric_name`（OOF 評価） | `direction` |
|---|---|---|
| regression | Config の `evaluation.metrics[0]` またはデフォルト `rmse` | `minimize` |
| binary | Config の `evaluation.metrics[0]` またはデフォルト `auc` | メトリクスの `greater_is_better` に従う |
| multiclass | Config の `evaluation.metrics[0]` またはデフォルト `logloss` | メトリクスの `greater_is_better` に従う |

#### 2. SearchDim のカテゴリ拡張

`SearchDim` にカテゴリ属性を追加し、Tuner がパラメーターの適用先を区別できるようにする。

- `model`: `LGBMAdapter.params` に渡す（現行通り）
- `smart`: `LGBMConfig` のスマートパラメーターとして `resolve_smart_params()` に渡す
- `training`: trial ごとに `EarlyStoppingConfig` / `InnerValidStrategy` を再構築

#### 3. Tuner の拡張

- `estimator_factory` のシグネチャを拡張し、smart params と training params を受け取れるようにする。
- `validation_ratio` が探索対象の場合、trial ごとに `InnerValidStrategy` を再構築する（`inner_valid_factory` パターン）。
- `early_stopping_rounds` が探索対象の場合、trial ごとに `LGBMAdapter` の `early_stopping_rounds` を変更する。

#### 4. Config の挙動

- `tuning.optuna.space` が空 `{}` → デフォルト空間を自動適用。
- `tuning.optuna.space` が指定されている → ユーザー指定を使用（現行通り）。
- デフォルト空間の個別次元を上書きしたい場合は、`space` に該当キーを指定する（デフォルトとマージ）。

### Impact

- `lizyml/tuning/search_space.py`: `default_space(task)` 関数追加、`SearchDim` にカテゴリ属性追加。
- `lizyml/tuning/tuner.py`: smart params / training params の per-trial 適用ロジック追加、`inner_valid_factory` パターン導入。
- `lizyml/core/model.py`: `tune()` でデフォルト空間の自動適用、拡張 `estimator_factory` / `inner_valid_factory` の構築。
- `lizyml/config/schema.py`: `OptunaConfig.space` が空の場合のデフォルト挙動を文書化。

### Compatibility

- 既存の `tuning.optuna.space` 指定は変更なく動作する。
- `space` 未指定時の挙動が変わる: 現在は空 space でエラーまたは探索なし → 今後はデフォルト空間が適用される。`0.x` のため許容。
- `Tuner` の内部 API（`estimator_factory` シグネチャ）が変わるが、内部 API のため影響は限定的。

### Alternatives Considered

- デフォルト空間を Config テンプレートとしてドキュメントで提供 → ユーザーが毎回コピーする手間がかかるため却下。
- training params を探索対象に含めない → `early_stopping_rounds` と `validation_ratio` は精度に大きく影響するため、デフォルトに含める。
- `brier` をカスタム feval で LightGBM に渡す → 実装コストが高く、将来の拡張点とする。

### Acceptance Criteria

- `tuning.optuna.space` が空の場合、タスク別デフォルト空間が自動適用される。
- regression の objective が `[huber, fair]` から探索される。
- multiclass の objective が `[multiclass, multiclassova]` から探索される。
- `learning_rate` が対数スケールで `[0.0001, 0.1]` の範囲で探索される。
- `num_leaves_ratio` が `[0.5, 1.0]` の範囲で探索され、`auto_num_leaves=True` で解決される。
- `early_stopping_rounds` が trial ごとに変更される。
- `validation_ratio` が trial ごとに `InnerValidStrategy` を再構築する。
- ユーザー指定の `space` がデフォルトを上書きする。
- `first_metric_only=True` と `metric` がデフォルトで固定適用される。
- Binary の metric に `brier` が含まれない（ネイティブ未対応）。
- 全テスト・lint・mypy 通過。

### Decision

- Date: `2026-03-05`
- Result: `accepted`
- Notes: `feat/phase-20-classification-enhancements` ブランチで実施。`SearchDim` に `category` 属性追加。`default_space(task)` を10次元（model/smart/training）に拡張。`default_fixed_params(task)` と `split_by_category()` を追加。Tuner を拡張し smart/training params の per-trial 適用を実装。`resolve_smart_params_from_dict()` を追加。

---

## 2026-03-05: Phase 20/21 監査乖離の是正タスク追加

- ID: `H-0025`
- Status: `accepted`
- Scope: `Public API | Config | Training | Notebook`
- Related: `BLUEPRINT.md §4.4, §5.3, §10.3, §13.3`

### 目的

Phase 20/21 の Requirements Audit で検出された部分的乖離を、仕様変更ではなく「既存仕様への整合修正」として計画化し、次タスクで確実に是正する。

対象の乖離は以下の 4 点。

1. `Model.load()` 後の `probability_histogram_plot()` が実行可能で、他の「学習時ターゲット必須API」と境界不整合。
2. `GroupHoldoutInnerValid` の validation group 選定が「shuffle 後末尾」であり、仕様の「末尾 group 割当」と不一致。
3. `LGBMConfig` の `min_data_in_leaf_ratio` / `min_data_in_bin_ratio` に `(0,1)` 範囲検証が未実装。
4. Notebook の LightGBM パラメーター確認セルで、スマートパラメーター表示項目が仕様要求を完全網羅していない。

### Proposal

#### 1. load 後 API 境界の統一

- `Model.probability_histogram_plot()` でも `self._y is None` を検知し、`MODEL_NOT_FIT` を返す。
- `roc_curve_plot()` / `confusion_matrix()` / `calibration_plot()` と同じ境界に揃える。

#### 2. GroupHoldout の割当方針を仕様準拠化

- `GroupHoldoutInnerValid` を「入力順の末尾 group を validation」に変更する。
- group overlap 禁止は維持する。
- 時系列/順序データでの再現可能な挙動を優先する。

#### 3. smart ratio の範囲バリデーション追加

- `min_data_in_leaf_ratio`: `0 < ratio < 1`
- `min_data_in_bin_ratio`: `0 < ratio < 1`
- 範囲外は `CONFIG_INVALID` とする。

#### 4. Notebook 確認セルの網羅化

- `tutorial_regression_lgbm.ipynb` に `min_data_in_bin_ratio`, `feature_weights`, `balanced` の表示を追加。
- `tutorial_binary_lgbm.ipynb` / `tutorial_multiclass_lgbm.ipynb` にも同等の確認セルを揃える。

### 影響範囲

- `lizyml/core/model.py`
- `lizyml/training/inner_valid.py`
- `lizyml/config/schema.py`
- `tests/test_*`（load後境界、group holdout、ratio検証）
- `notebooks/tutorial_regression_lgbm.ipynb`
- `notebooks/tutorial_binary_lgbm.ipynb`
- `notebooks/tutorial_multiclass_lgbm.ipynb`

### 互換性

- 公開メソッド追加/削除はない。既存 API surface は維持。
- `probability_histogram_plot()` の load 後挙動のみ厳格化（仕様準拠）。
- `GroupHoldoutInnerValid` の group 選定規則が変わるため、同一 seed でも inner split が変わる可能性がある（仕様準拠の挙動変更）。
- Config の ratio 範囲外指定は新たに早期エラーとなる。

### 代替案

- 現行挙動を仕様側に合わせて変更する: 監査で仕様準拠を優先する方針のため採用しない。
- `GroupHoldoutInnerValid` に `shuffle_groups` フラグを追加し両対応する: Config/API の複雑化を避けるため採用しない。
- Notebook は regression のみ更新する: 21-C で binary/multiclass への横展開方針があるため採用しない。

### 受け入れ基準

- `Model.load()` 後の `probability_histogram_plot()` が `MODEL_NOT_FIT` を返す。
- `GroupHoldoutInnerValid` が入力順末尾 group を validation に割り当て、group overlap が発生しない。
- `min_data_in_leaf_ratio` / `min_data_in_bin_ratio` の `<=0` または `>=1` が `CONFIG_INVALID` になる。
- 3つの Notebook でスマートパラメーター確認セルが同等方針で揃う。
- 追加/更新テストが通過する。

---

## 2026-03-05: `Model.load()` 後に診断APIを利用可能にする仕様変更

- ID: `H-0026`
- Status: `accepted`
- Scope: `Public API | Persistence`
- Related: `BLUEPRINT.md §4.1, §6.5, §7.4, §15.3`
- Supersedes: `H-0025` の「1. load 後 API 境界の統一」

### 目的

`Model.load()` 後の利用体験を「推論・評価参照のみ」から「診断APIも含む」に拡張し、学習実行環境がない場面でも残差分析・SHAP 重要度・分類/校正可視化を再利用できるようにする。

対象 API:

- `residuals()`
- `residuals_plot()`
- `importance(kind="shap")`
- `roc_curve_plot()`
- `confusion_matrix()`
- `calibration_plot()`
- `probability_histogram_plot()`

### Proposal

1. `Model.load()` 後でも上記 API を利用可能とする（`fit()` 後と同等の利用境界）。
2. Exported Model Artifacts に load 後診断APIで必要な最小データを `analysis_context` として含める。
   - `y_true`（学習時ターゲット）
   - `X_for_explain`（SHAP重要度算出に必要な特徴量データ）
3. `Model.load()` は `analysis_context` を復元し、診断APIが追加データ入力なしで動作するようにする。

### 影響範囲

- `BLUEPRINT.md`（公開API境界、export/load、artifacts 契約）
- `lizyml/persistence/*`（保存/読込対象）
- `lizyml/core/model.py`（load 後 API ガード）
- `tests/test_plots/*`, `tests/test_explain/*`（load 後境界テスト）

### 互換性

- 公開 API は拡張のみで、既存メソッドの削除はない。
- 既存 artifact（`analysis_context` 未保持）については migration 方針を定義し、少なくとも以下を保証する。
  - `predict()` / `evaluate()` は従来どおり利用可能。
  - 追加された load 後診断 API は、必要データがない場合に明示的エラーを返すか、再 export を促す。

### 代替案

- 現行どおり load 後は診断 API を禁止する: ユースケース拡張の目的を満たせないため採用しない。
- 診断 API ごとに外部から `y_true`/`X` を都度受け取る: Facade 利用性が低下し API 一貫性を損なうため採用しない。

### 受け入れ基準

- `Model.load()` 後に対象 7 API が呼び出し可能である。
- `export` 成果物に `analysis_context` が含まれる。
- load 後診断 API の回帰・分類・校正系テストが通過する。
- 既存 artifact 互換方針がドキュメント化され、テストで担保される。

### Decision

- Date: `2026-03-05`
- Result: `accepted`
- Notes: API 境界を「fit 後のみ」から「fit 後 + load 後」に拡張する方針を採用。

---

## 2026-03-06: Config Reference の BLUEPRINT 反映と README デフォルト値修正

- ID: `H-0027`
- Status: `accepted`
- Scope: `Config`
- Related: `BLUEPRINT.md §5.4`

### 目的

README に記載されている Config Reference（全キー・デフォルト値・バリデーション制約の一覧表）を BLUEPRINT に正式な仕様として反映する。併せて、スキーマ実装のデフォルト値を README（仕様の正）に合わせて修正する（`min_data_in_leaf_ratio: None`→`0.01`, `min_data_in_bin_ratio: None`→`0.01`, `balanced: False`→`None`（タスク依存自動解決: regression→False, binary/multiclass→True））。

### Proposal

1. BLUEPRINT §5.4 として「Config Reference（全キー一覧）」セクションを追加し、README の Config Reference の内容を仕様として固定する。
2. スキーマ実装（`schema.py`）のデフォルト値を README に合わせて修正する。

### 影響範囲

- BLUEPRINT.md §5.4（新規セクション追加）
- `lizyml/config/schema.py`（デフォルト値の修正）

### 互換性

- デフォルト値の変更により、既存の Config で明示指定していないユーザーの動作が変わる。ただし README を参照しているユーザーにとっては期待通りの動作となる。

### 代替案

- README にのみ記載し BLUEPRINT に反映しない: 仕様の正が分散するため却下。

### 受け入れ基準

- BLUEPRINT §5.4 に全 Config キーの型・デフォルト・制約が記載されている。
- README のデフォルト値がスキーマ実装と一致している。

### Decision

- Date: `2026-03-06`
- Result: `accepted`
- Notes: 仕様の明文化。`balanced` のデフォルトは `None`（タスク依存自動解決: regression→False, binary/multiclass→True）に変更。`min_data_in_leaf_ratio=0.01`, `min_data_in_bin_ratio=0.01` をデフォルトに設定。

---

## 2026-03-06: Tuning 探索状況の可視化 (`tuning_plot`)

- ID: `H-0028`
- Status: `accepted`
- Scope: `Public API | Plots`
- Related: `BLUEPRINT.md §4.1, §13.3`

### 目的

`tune()` 実行後に探索状況を可視化する `model.tuning_plot()` を公開 API に追加する。Optuna の最適化履歴（trial ごとのスコア推移）を Plotly で描画する。

### Proposal

1. `Model.tuning_plot()` を追加する。`tune()` 未実行時は `MODEL_NOT_FIT`。
2. X 軸 = trial 番号、Y 軸 = スコア値。完了/枝刈り/失敗を色分けする。最良スコアの推移ラインも重ね描きする。
3. 実装は `plots/tuning.py` に配置し、Model には委譲のみ。
4. Plotly optional dependency。

### 影響範囲

- `BLUEPRINT.md §4.1`（公開 API 追加）
- `BLUEPRINT.md §13.3`（可視化追加）
- `lizyml/plots/tuning.py`（新規）
- `lizyml/core/model.py`（委譲メソッド追加）

### 互換性

- 追加のみ。破壊的変更なし。

### 代替案

- Optuna の built-in visualization を直接使う: Optuna 依存を公開 API に露出させるため却下。

### 受け入れ基準

- `model.tuning_plot()` が Plotly Figure を返す。
- 完了/枝刈り/失敗の trial が区別される。
- 最良スコア推移ラインが描画される。
- `tune()` 未実行時に `MODEL_NOT_FIT`。
- Plotly 未インストール時に `OPTIONAL_DEP_MISSING`。

### Decision

- Date: `2026-03-06`
- Result: `accepted`
- Notes: Phase 22 追加開発で実装。

---

## 2026-03-06: `Model.fit_result` プロパティの追加

- ID: `H-0029`
- Status: `accepted`
- Scope: `Public API`
- Related: `BLUEPRINT.md §4.1`

### 目的

`fit()` 後の `FitResult` をユーザーが直接参照できる read-only プロパティ `model.fit_result` を追加する。これにより、Notebook 等で学習結果の詳細（models, history, splits 等）を直接確認できる。

### Proposal

1. `Model.fit_result` プロパティを追加する（`@property`、read-only）。
2. `fit()` 未実行時は `MODEL_NOT_FIT`。
3. Model クラス内に新しいロジックは追加しない（既存の `self._fit_result` を返すだけ）。

### 影響範囲

- `BLUEPRINT.md §4.1`（公開 API 追加）
- `lizyml/core/model.py`（プロパティ追加のみ）

### 互換性

- 追加のみ。破壊的変更なし。

### 代替案

- `fit()` の戻り値だけで十分とする: `tune()` → `fit()` の流れで戻り値を使わない場合にアクセスできなくなるため却下。

### 受け入れ基準

- `model.fit_result` が `FitResult` を返す。
- `fit()` 未実行時に `MODEL_NOT_FIT`。

### Decision

- Date: `2026-03-06`
- Result: `accepted`
- Notes: Phase 22 追加開発で実装。

---

## 2026-03-06: Calibration に生スコア（logits）を渡す仕様の明確化

- ID: `H-0030`
- Status: `accepted`
- Scope: `Calibration`
- Related: `BLUEPRINT.md §12.1`

### 目的

現在の BLUEPRINT §12.1 では校正器の入力を「OOF スコア」と記載しているが、確率値（predict_proba の出力）なのか生スコア（logits）なのかが曖昧。LightGBM の binary タスクでは predict_proba が sigmoid 適用後の確率を返すため、現状は確率値が渡されている。しかし校正の理論的正しさの観点から、校正器には生スコア（raw score / logits。sigmoid/softmax 適用前）を渡すべきである。

### Proposal

1. BLUEPRINT §12.1 を更新し、校正器への入力は「Base モデルの OOF 生スコア（raw score / logits）」であることを明示する。
2. `EstimatorAdapter` に `predict_raw(X)` メソッドを追加し、sigmoid/softmax 適用前の生スコアを返す手段を提供する。
3. `BaseCalibratorAdapter.fit()` の入力を確率値から生スコアに変更する。
4. `BaseCalibratorAdapter.predict()` は生スコアを受け取り、校正済み確率を返す。
5. Platt / Isotonic / Beta の各実装を生スコア入力に対応させる。
6. Calibration が未指定の場合は従来どおり `predict_proba`（確率値）を OOF/IF 予測に使用する。Calibration 有効時のみ生スコアベースの校正パスに入る。

### 影響範囲

- `BLUEPRINT.md §12.1`（入力仕様の変更）
- `BLUEPRINT.md §14.1`（`predict_raw` メソッド追加）
- `lizyml/estimators/base.py`（`predict_raw` 追加）
- `lizyml/estimators/lgbm.py`（`predict_raw` 実装）
- `lizyml/calibration/base.py`（IF 変更）
- `lizyml/calibration/platt.py`, `isotonic.py`（入力変更）
- `lizyml/calibration/cross_fit.py`（raw score を渡すよう変更）
- `lizyml/training/cv_trainer.py`（OOF 生スコア生成）

### 互換性

- `BaseCalibratorAdapter` の入力形式変更は破壊的。ただし Calibration は内部 IF であり公開 API ではないため、format_version 変更は不要。
- 既存 artifact の calibrator は確率値で学習されているため、load 互換に注意が必要。

### 代替案

- 確率値入力のまま維持する: 校正の理論的正しさが損なわれるため却下。

### 受け入れ基準

- `EstimatorAdapter.predict_raw()` が生スコアを返す。
- 校正器が生スコアで学習される。
- cross-fit 校正が raw score ベースで動作する。
- Calibration 未指定時は `predict_proba` で OOF/IF を生成する（動作変更なし）。
- BLUEPRINT §12.1 に入力形式が明記されている。

### Decision

- Date: `2026-03-06`
- Result: `accepted`
- Notes: Phase 23 で実装。Calibration IF の入力を確率値から生スコアに変更。Calibration 未使用時は従来の predict_proba パスを維持。

---

## 2026-03-06: Beta Calibration の実装

- ID: `H-0031`
- Status: `accepted`
- Scope: `Calibration`
- Related: `BLUEPRINT.md §12.2`

### 目的

BLUEPRINT §12.2 で列挙されている 3 つの校正手法（Platt / Beta / Isotonic）のうち、Beta Calibration のみ未実装。これを実装する。

### Proposal

1. `lizyml/calibration/beta.py` に `BetaCalibrator(BaseCalibratorAdapter)` を実装する。
2. Beta Calibration は `a * log(s) + b * log(1-s) + c` の 3 パラメーターモデルで、`scipy.optimize.minimize` で最適化する。
3. `calibration/registry.py` の `_NOT_IMPLEMENTED` から `"beta"` を削除し、正式に登録する。

### 影響範囲

- `lizyml/calibration/beta.py`（新規）
- `lizyml/calibration/registry.py`（登録変更）

### 互換性

- Config で `method="beta"` を指定可能になる（以前は `CALIBRATION_NOT_SUPPORTED` エラー）。
- 既存の Platt / Isotonic には影響なし。

### 代替案

- 外部ライブラリ（`betacal`）を依存に追加する: optional dependency を増やしたくないため、自前実装を選択。

### 受け入れ基準

- `method="beta"` で校正が動作する。
- cross-fit + OOF-only の契約を満たす。
- Platt / Isotonic と同一の BaseCalibratorAdapter IF を実装する。

### Decision

- Date: `2026-03-06`
- Result: `accepted`
- Notes: Phase 23 で実装。

---

## 2026-03-06: PurgedTimeSeries / GroupTimeSeries の Config・Model 接続

- ID: `H-0032`
- Status: `accepted`
- Scope: `Config | Split`
- Related: `BLUEPRINT.md §5, §10.2`

### 目的

Splitter クラス（`PurgedTimeSeriesSplitter`, `GroupTimeSeriesSplitter`）は実装済みだが、Config schema に対応する `Literal` がなく、`Model._build_splitter()` にルーティングもないため、ユーザーが利用できない。Config と Model を接続する。

### Proposal

1. Config schema に `PurgedTimeSeriesConfig`（`method: Literal["purged_time_series"]`）と `GroupTimeSeriesConfig`（`method: Literal["group_time_series"]`）を追加する。
2. `SplitConfig` の Union に上記を追加する。
3. `Model._build_splitter()` に `purged_time_series` / `group_time_series` のルーティングを追加する。
4. InnerValid 自動解決テーブルに `purged_time_series` → `time_holdout`、`group_time_series` → `group_holdout` を追加する。
5. 正規化エイリアスを追加する（`purged-time-series` → `purged_time_series` 等）。

### 影響範囲

- `lizyml/config/schema.py`（Config 追加）
- `lizyml/config/loader.py`（正規化追加）
- `lizyml/core/model.py`（ルーティング追加）
- `BLUEPRINT.md §5, §10.2, §10.3`

### 互換性

- 追加のみ。既存の 4 split method に影響なし。

### 代替案

- なし。

### 受け入れ基準

- `split.method: "purged_time_series"` / `"group_time_series"` で CV が動作する。
- InnerValid が自動解決される。
- 正規化エイリアスが機能する。

### Decision

- Date: `2026-03-06`
- Result: `accepted`
- Notes: Phase 23 で実装。

---

## 2026-03-06: 時系列 fold 期間情報の表示

- ID: `H-0033`
- Status: `accepted`
- Scope: `Public API | Plots`
- Related: `BLUEPRINT.md §13.3`

### 目的

時系列分割（`time_series` / `purged_time_series` / `group_time_series`）使用時に、fold ごとの期間情報（train の終端、valid の開始）を確認できる手段を提供する。

### Proposal

1. `FitResult.splits` に `time_col` の min/max 情報を fold ごとに記録する（`time_range` フィールド: `list[dict] | None`）。
2. `model.split_summary()` メソッドを追加し、fold ごとの期間情報を `pd.DataFrame` で返す。列: `fold`, `train_start`, `train_end`, `valid_start`, `valid_end`, `train_size`, `valid_size`。
3. 時系列でない場合は `time_range` なし、`split_summary()` は size 情報のみ返す。

### 影響範囲

- `BLUEPRINT.md §7.1`（FitResult.splits 拡張）
- `BLUEPRINT.md §4.1`（公開 API 追加）
- `lizyml/core/types/fit_result.py`（フィールド追加）
- `lizyml/core/model.py`（委譲メソッド追加）

### 互換性

- FitResult に optional フィールド追加。既存 artifact の load 互換は維持（`time_range` が None の場合はサイズ情報のみ）。

### 代替案

- 可視化（Gantt chart）のみ提供する: DataFrame 出力の方が汎用性が高いため、まず DataFrame を提供。

### 受け入れ基準

- 時系列分割時に `FitResult.splits` に期間情報が含まれる。
- `model.split_summary()` が DataFrame を返す。
- 非時系列でも size 情報は返す。

### Decision

- Date: `2026-03-06`
- Result: `accepted`
- Notes: Phase 23 で実装。

---

## 2026-03-06: Logging 出力先の統一

- ID: `H-0034`
- Status: `accepted`
- Scope: `Logging`
- Related: `BLUEPRINT.md §17`

### 目的

BLUEPRINT §17 で規定されている「`run_id` に基づく出力先（logs / artifacts / plots）の統一」が未実装。run_id ベースのディレクトリ管理を実装する。

### Proposal

1. `Model` に `output_dir` オプションを追加する（`Config` の `output` セクション or コンストラクタ引数）。
2. `output_dir` 指定時、`run_id` ベースのサブディレクトリ（`{output_dir}/{run_id}/`）を自動作成し、ログ・plot 保存先とする。
3. `output_dir` 未指定時は現行動作（ログは標準出力、plot は返却のみ）を維持する。

### 影響範囲

- `BLUEPRINT.md §17`（仕様の具体化）
- `lizyml/core/logging.py`（出力先管理）
- `lizyml/core/model.py`（output_dir の受け渡し）

### 互換性

- `output_dir` はオプションのため既存動作に影響なし。

### 代替案

- MLflow 等の外部ツールに委ねる: 将来の拡張点として残すが、最小限の自前管理は必要。

### 受け入れ基準

- `output_dir` 指定時に `{output_dir}/{run_id}/` が作成される。
- ログファイルが出力先に保存される。
- 未指定時は既存動作を維持する。

### Decision

- Date: `2026-03-06`
- Result: `accepted`
- Notes: Phase 23 で実装。

---

## 2026-03-06: 解決済みパラメーターテーブル API

- ID: `H-0035`
- Status: `accepted`
- Scope: `Public API`
- Related: `BLUEPRINT.md §4.1`

### 目的

Notebook の「4.1 LightGBM Parameters」セルで手動実装しているパラメーター確認コードを、Model の公開メソッドとして提供する。ユーザーが booster 内部にアクセスする必要をなくし、1 行で解決済みパラメーターを確認できるようにする。

### Proposal

1. `model.params_table()` メソッドを追加する。
   - 戻り値: `pd.DataFrame`（index: `parameter`, 単一列: `value`）。
   - Config 由来の smart params（`auto_num_leaves`, `num_leaves_ratio`, `min_data_in_leaf_ratio`, `min_data_in_bin_ratio`, `balanced`, `feature_weights`）と training 設定（`early_stopping.rounds`, `validation_ratio`）を含む。
   - fold 0 の学習済み booster から取得した解決済みネイティブパラメーター（`objective`, `num_leaves`, `min_data_in_leaf`, `min_data_in_bin`, `max_bin`, `learning_rate`, `max_depth`, `feature_fraction`, `bagging_fraction`, `bagging_freq`, `lambda_l2`, `num_iterations` 等）を含む。
   - Config smart params（ratio 等）と resolved params（絶対値）は名前が異なるため衝突しない。同一テーブルに混在させることで、ユーザーは「指定した ratio」と「解決された絶対値」を対比確認できる。
   - 末尾に fold ごとの `best_iteration` 行を追加する。
   - `fit()` 未実行時は `MODEL_NOT_FIT` を送出する。
2. 出力イメージ:
   ```
                             value
   parameter
   objective                huber
   learning_rate            0.001
   max_depth                    5
   auto_num_leaves           True
   num_leaves_ratio           1.0
   num_leaves                  32
   min_data_in_leaf_ratio    0.01
   min_data_in_leaf           540
   min_data_in_bin_ratio    0.001
   min_data_in_bin             54
   max_bin                    511
   feature_fraction           0.7
   bagging_fraction           0.7
   bagging_freq                10
   lambda_l2             0.000001
   balanced                 False
   early_stopping_rounds      150
   validation_ratio           0.1
   num_iterations            1500
   best_iteration_0           487
   best_iteration_1           512
   ...
   ```

### 影響範囲

- `BLUEPRINT.md §4.1`（公開 API 追加）
- `lizyml/core/model.py`（委譲メソッド追加）
- Notebook の「4.1」セルを `model.params_table()` 1 行に置き換え可能

### 互換性

- 新規メソッド追加のみ。既存 API に変更なし。

### 代替案

- 2 列（`config` / `resolved`）で対比する: ratio → 絶対値の対応を明示的に示せるが、多くのパラメーターで片方が空欄になり冗長。単一列で十分識別可能（名前が異なるため）。

### 受け入れ基準

- `model.params_table()` が `pd.DataFrame` を返す。
- Config smart params と resolved booster params が同一テーブルに含まれる。
- fold ごとの `best_iteration` が含まれる。
- `fit()` 未実行時に `MODEL_NOT_FIT` を送出する。
- Notebook の「4.1」セルを `model.params_table()` に置き換えて動作確認。

---

## 2026-03-06: Smart Parameter の n_rows 基準を inner train サイズに変更

- ID: `H-0036`
- Status: `accepted`
- Scope: `Result の意味・shape（smart param 解決ロジック）`
- Related: `BLUEPRINT.md §5.3`

### 目的

Smart parameter（`min_data_in_leaf_ratio`, `min_data_in_bin_ratio`）の `n_rows` 基準が、現在は `fit()` に渡された全データセットサイズを使用している。実際にモデルが学習するデータは outer fold 分割 + inner valid 分割後のサブセットであり、5-fold + validation_ratio=0.1 の場合は全体の約 72% に減少する。ratio パラメーターの意図（実際の学習データサイズに対する割合）と乖離するため、n_rows を inner train サイズ（early stopping 用 validation 分割後）に変更する。

### Proposal

1. smart parameter の `n_rows` を「CVTrainer の各 fold における inner_valid 分割後の学習データ行数」とする。
2. `Model.fit()` での一括解決（現行）を廃止し、`CVTrainer.fit()` 内の fold ループ内で、inner_valid 分割後に smart params を解決する。
3. `auto_num_leaves` は `max_depth` のみに依存し `n_rows` を使わないため影響なし。`num_leaves_ratio` も `max_depth` ベースのため影響なし。影響を受けるのは `min_data_in_leaf_ratio` と `min_data_in_bin_ratio` のみ。
4. Tuner の trial 内でも同様に、CVTrainer 内部で fold ごとに解決する。
5. BLUEPRINT §5.3 の記述を更新し、`n_rows` の定義を明確化する。
6. `feature_weights` と `balanced`（`sample_weight`）は n_rows に依存しないため影響なし。

### 影響範囲

- `BLUEPRINT.md §5.3`（n_rows の定義明確化）
- `lizyml/core/model.py`（smart param 解決の移動）
- `lizyml/training/cv_trainer.py`（fold 内での smart param 解決追加）
- `lizyml/estimators/lgbm.py`（`resolve_smart_params` のインターフェース変更の可能性）
- `lizyml/tuning/tuner.py`（trial 内 smart param 解決の変更）
- `params_table()` の出力（fold ごとに異なる可能性のある値の表示方針）

### 互換性

- ratio の解決値が変わるため、同一 Config でも以前と異なる `min_data_in_leaf` / `min_data_in_bin` 値が生成される（破壊的変更）。
- ただし Artifacts の `format_version` や公開 API のシグネチャには影響しない。
- 既存の保存済みモデルには影響しない（解決済みパラメーターは booster に格納済み）。

### 代替案

1. **全データセットサイズ基準を維持し仕様明確化のみ**: 安定性・再現性の観点で合理的だが、ratio の意味が「全データに対する割合」に固定される。
2. **outer fold 基準**: inner valid 分割は考慮しない中間案。fold 間で均等分割なら安定するが、不均等分割（時系列等）では fold ごとに異なる。

### 受け入れ基準

- `min_data_in_leaf_ratio=0.01` で 5-fold + validation_ratio=0.1 の場合、解決値が全データの 0.72% 付近（inner train サイズ基準）になることをテストで確認。
- fold ごとの解決値が inner train サイズに基づいて正しく計算されること。
- Tuner の trial 内でも同一ロジックが適用されること。
- `params_table()` が fold 0 の解決値を正しく表示すること。
- 既存テストの回帰確認（seed 固定テストの期待値更新が必要な場合あり）。

---

## 2026-03-06: Phase 22 監査乖離クローズ — ドキュメント整合修正

- ID: `H-0037`
- Status: `accepted`
- Scope: `ドキュメント整合（BLUEPRINT 文言修正 + Notebook/テスト補完）`
- Related: `BLUEPRINT.md §5.2, §5.3, §5.4`, `PLAN.md Phase 22`

### 目的

Phase 22 監査で検出された BLUEPRINT の記述乖離（§5.3 balanced デフォルト、§5.2/§5.3 LGBMConfig 例）を実装/§5.4 と統一する。合わせて Notebook の feature_weights 解決後値確認セルと静的テストの不足を補完し、監査乖離を完全にクローズする。

### 対象

1. **BLUEPRINT §5.3 balanced 記述**: `balanced: bool = False` → `balanced: bool | None = None`（タスク依存自動解決: regression→False, binary/multiclass→True）。§5.4 Config Reference と一致させる。
2. **BLUEPRINT §5.2/§5.3 LGBMConfig 例**: `min_data_in_leaf_ratio` / `min_data_in_bin_ratio` / `balanced` の説明文を現仕様（デフォルト値・自動解決ロジック）と一致するよう微修正。
3. **Notebook**: `tutorial_regression_tuning_lgbm.ipynb` に feature_weights (resolved) の確認セルを追加（設定時のみ表示）。
4. **テスト**: `tests/test_notebooks/test_notebook_cells.py` に feature_weights 解決後値確認セルの存在検証を追加。

### 影響範囲

- BLUEPRINT.md の文言修正のみ。公開 API / Config / Result の shape は変更なし。
- Notebook セル追加と静的テスト追加は既存動作に影響なし。

### 互換性

- 破壊的変更なし。

### 受け入れ基準

- BLUEPRINT §5.3 の balanced デフォルト記述が §5.4 Config Reference と一致していること。
- BLUEPRINT §5.2/§5.3 の LGBMConfig 例が現仕様と一致していること。
- `tutorial_regression_tuning_lgbm.ipynb` に feature_weights (resolved) セルがあること。
- Notebook 静的テストが feature_weights 解決後値確認セルの存在を検証すること。

### Decision

- Date: `2026-03-06`
- Result: `accepted`
- Notes: 変更ゲート非該当（文言修正 + テスト追加）。BLUEPRINT §5.2/§5.3 を修正し、開発タスクは Phase 22 の 22-O として追加。

---

## 2026-03-06: Phase 23 監査フォローアップ（23-C: BLUEPRINT準拠）

- ID: `H-0038`
- Status: `accepted`
- Scope: `Config | Split`
- Related: `BLUEPRINT.md §5.4, §10.2`, `PLAN.md Phase 23`

### Context

Requirements Audit の結果、Phase 23-C について BLUEPRINT と実装の乖離が確認された。  
BLUEPRINT §5.4 は `purged_time_series` の固有キーを `purge_gap` / `embargo_pct` と定義している一方、現実装は `purge_window` / `gap` を受け付けている。

本件は公開 Config 契約（split 設定）に該当するため、BLUEPRINT を正として整合させる方針を明示する。

### Proposal

1. `purged_time_series` の正式キーは BLUEPRINT 記載どおり `purge_gap` / `embargo_pct` とする。
2. `config/schema.py`・`config/loader.py`・`core/model.py`・splitter 実装を上記キー契約に合わせて更新する。
3. 既存ユーザー向けに `purge_window` / `gap` は移行期間中のみ後方互換として受け付け、明示警告を出す。
4. `embargo_pct` の split 動作をテストで固定し、リーク防止境界を明文化する。

### Impact

- `lizyml/config/schema.py`
- `lizyml/config/loader.py`
- `lizyml/core/model.py`
- `lizyml/splitters/purged_time_series.py`
- `tests/test_config/*`, `tests/test_e2e/test_time_series_splits.py`

### Compatibility

- 公開 Config 契約の是正であり、最終的には破壊的（legacy key 廃止時）。
- ただし移行期間を設け、legacy key を警告付きで受理することで段階移行可能とする。

### Alternatives Considered

1. 実装に合わせて BLUEPRINT を `purge_window` / `gap` に変更する  
   - 不採用。ユーザー指示（23-C は BLUEPRINT を正とする）と矛盾するため。
2. 互換レイヤーなしで即時切替する  
   - 不採用。既存 Config 利用者への影響が大きいため。

### Acceptance Criteria

- `split.method: "purged_time_series"` で `purge_gap` / `embargo_pct` が有効に解釈される。
- `purge_window` / `gap` 指定時は警告付きで同等動作し、移行案内が表示される。
- `embargo_pct` を含む split でリーク防止境界のテストが追加され、期待どおりに通過する。
- BLUEPRINT §5.4 / §10.2 と実装・テストのキー名が一致する。

### Migration

- 既存 Config の `purge_window` / `gap` は `purge_gap` / `embargo_pct` に置換する。
- 移行期間中は legacy key を警告付きで受理し、将来削除時期をリリースノートで告知する。

---

## 2026-03-06: Phase 23 監査フォローアップ（23-F: output_dir 契約完了）

- ID: `H-0039`
- Status: `accepted`
- Scope: `Config | Logging`
- Related: `BLUEPRINT.md §17`, `PLAN.md Phase 23`

### Context

Requirements Audit の結果、23-F は部分達成。  
現状は `Model(..., output_dir=...)` + `fit()` の経路のみ動作し、BLUEPRINT §17 の「Config or コンストラクタ」「fit/tune/export の統一出力先」要件を満たし切れていない。

### Proposal

1. `output_dir` を Config からも指定可能にする（優先順位は `constructor > config > 未指定`）。
2. `fit` だけでなく `tune` / `export` でも `{output_dir}/{run_id}/` を作成し、ログ出力を統一する。
3. 既存の未指定時挙動（標準出力中心、返却API中心）は維持する。

### Impact

- `lizyml/config/schema.py`
- `lizyml/core/model.py`
- `lizyml/core/logging.py`
- `tests/test_core/test_logging_output.py`（拡張）

### Compatibility

- 追加機能であり後方互換。
- `output_dir` 未指定ユーザーの挙動変更はない。

### Alternatives Considered

1. コンストラクタ引数のみ対応のまま維持する  
   - 不採用。BLUEPRINT §17 の契約に未達のため。
2. `fit` のみ対応のまま維持する  
   - 不採用。run 管理の統一要件を満たせないため。

### Acceptance Criteria

- Config 経由で `output_dir` を指定した場合に run ディレクトリが作成される。
- `fit` / `tune` / `export` の各経路で run ディレクトリとログファイルが作成される。
- コンストラクタ引数と Config 両方がある場合、優先順位がテストで保証される。
- 未指定時の既存挙動が回帰しない。

### Migration

- 移行必須なし（任意で Config に `output_dir` を追加可能）。

---

## 2026-03-07: TimeSeries CV 方針更新（time_col基準統一 + embargo改名）

- ID: `H-0040`
- Status: `accepted`
- Scope: `Config | Split | InnerValid`
- Related: `BLUEPRINT.md §5.4, §6.2, §10.2, §10.3`, `PLAN.md Phase 23`

### Context

TimeSeries 系 split（`time_series` / `purged_time_series` / `group_time_series`）の仕様が、`time_col` の扱い・パラメーター命名・ウィンドウ制御の観点で統一されていない。  
現状は「行順ベース」の実装が混在しており、ユーザーが `time_col` を指定しても split ロジックがその列で明示的にソートする契約になっていない。

### Proposal

1. 3 メソッド共通で `data.time_col` を必須化し、split 前に `time_col` 昇順で並べてから分割する。
2. 3 メソッド共通でウィンドウ制御キー `train_size_max` / `test_size_max` を持つ。
3. `time_series` / `group_time_series` は `gap`、`purged_time_series` は `purge_gap` を継続し、3 メソッドでギャップ指定を共通概念として扱う。
4. `purged_time_series` の `embargo_pct`（`float`）を `embargo`（`int`、Obs 数指定）に改名・型変更する。`gap` / `purge_gap` と同じ単位に統一。
5. 既存ユーザー向けに `embargo_pct` は移行期間中のみ警告付きで受理し、`int()` 変換の上 `embargo` へ正規化する。

### Impact

- `lizyml/config/schema.py`（split config 契約の更新）
- `lizyml/config/loader.py`（正規化・後方互換）
- `lizyml/core/model.py`（time_col 必須チェック、split 構築）
- `lizyml/splitters/time_series.py`
- `lizyml/splitters/purged_time_series.py`
- `lizyml/splitters/group_time_series.py`
- `lizyml/training/cv_trainer.py`（time_col 昇順前処理の適用位置に応じて）
- `tests/test_splitters/*`, `tests/test_e2e/test_time_series_splits.py`, `tests/test_e2e/test_split_summary.py`

### Compatibility

- `embargo_pct` -> `embargo` は公開 Config 契約の変更を含むため、最終的には破壊的。
- 移行期間中は `embargo_pct` を警告付き互換として受理し、段階移行可能にする。
- `time_col` 必須化は既存の「行順依存」設定に影響するため、エラーメッセージと移行ガイドを明示する。

### Alternatives Considered

1. 現行の「行順前提」運用を継続し、`time_col` 必須化しない  
   - 不採用。データ前処理依存で誤用しやすく、仕様の再現性を下げるため。
2. `embargo_pct` 名を維持して文言だけ調整する  
   - 不採用。指定単位の誤解が残るため、命名統一を優先。

### Acceptance Criteria

- 3 メソッドで `data.time_col` 未指定時は `CONFIG_INVALID` となる。
- `time_col` 非昇順データを与えても、`time_col` 昇順での分割結果が再現される。
- 3 メソッドすべてで `train_size_max` / `test_size_max` が有効に解釈される。
- `purged_time_series` で `embargo` が有効に動作する。
- `embargo_pct` 指定時は警告を出しつつ `embargo` と同等動作になる。
- 既存の leakage 防止テストと split_summary テストが回帰しない。

### Migration

- `split.method: "purged_time_series"` を使う既存 Config は `embargo_pct` を `embargo` に置換する。
- `time_series` / `purged_time_series` / `group_time_series` を使う既存 Config は `data.time_col` を必ず指定する。
- 既存の並び替え前提コードは、`time_col` の値が期待どおりの順序を持つことを確認する。

---

## 2026-03-07: LGBMAdapter: sklearn wrapper → Booster API 移行

- ID: `H-0041`
- Status: `accepted`
- Scope: `EstimatorAdapter | Training | Persistence`
- Related: `BLUEPRINT.md §14.2, §14.3`, `PLAN.md Phase 24`

### Context

LightGBM の sklearn wrapper（`LGBMRegressor` / `LGBMClassifier`）に、`early_stopping` callback 併用時に `model_to_string()` が空文字列を返す間欠バグが存在する（microsoft/LightGBM#7186）。
このバグは sklearn wrapper 内部の後処理（`engine.py:350` で `keep_training_booster=False` 時に実行される `model_from_string(model_to_string())` ラウンドトリップ）に起因し、約 5-10% の確率で `LightGBMError: Model file doesn't specify the number of classes` を発生させる。

LightGBM の Booster API（`lgb.train()`）では `keep_training_booster=True` がデフォルトであり、上記ラウンドトリップが発生しないため、このバグの影響を受けない。実際に 100 回の検証で 0 回の失敗を確認済み。

### Proposal

`LGBMAdapter.fit()` の内部実装を sklearn wrapper（`LGBMRegressor` / `LGBMClassifier`）から LightGBM Booster API（`lgb.train()`）に移行する。

1. **`fit()`**: `lgb.Dataset` を構築し、`lgb.train(params, train_set, valid_sets=[...], callbacks=[...], keep_training_booster=True)` で学習する。
2. **`predict()`**: `booster.predict(X)` を使用。regression はそのまま返却。classification は `objective` に応じて sigmoid/softmax 適用済みの値が返る。
3. **`predict_proba()`**: `booster.predict(X)` を使用。binary は `(n,)` → `(n, 2)` に変換。multiclass は `(n, k)` をそのまま返却。
4. **`predict_raw()`**: `booster.predict(X, raw_score=True)` を使用（現状と同じロジック、`booster_` 経由のアクセスが不要になる）。
5. **`importance()`**: `booster.feature_importance(importance_type=...)` を直接呼び出す。
6. **`get_native_model()`**: 戻り値を `lgb.Booster` に変更する。
7. **`best_iteration`**: `booster.best_iteration` から取得する。
8. **パラメーター変換**: sklearn 固有のパラメーター名（`n_estimators` → `num_boost_round`、`random_state` → `seed`）を Booster API に適切にマッピングする。

### Impact

- `lizyml/estimators/lgbm.py`（主要変更: fit / predict / predict_proba / get_native_model / _build_params）
- `lizyml/training/cv_trainer.py`（`evals_result_` → Booster API の `eval_results` への適応）
- `lizyml/training/refit_trainer.py`（同上）
- `lizyml/core/model.py`（`params_table()` の `.booster_` アクセスを `.get_native_model()` 直接に変更）
- `lizyml/explain/shap_explainer.py`（SHAP TreeExplainer は Booster を直接受け取れるため変更不要、ただし確認は必要）
- `lizyml/persistence/exporter.py`（joblib シリアライズ対象が Booster に変わるため確認）
- `tests/test_estimators/` `tests/test_e2e/`（`get_native_model()` 戻り値型、`.booster_` アクセスの更新）

### Compatibility

- **公開 API（`get_native_model()`）**: 戻り値が `LGBMRegressor | LGBMClassifier` → `lgb.Booster` に変更される。これは内部型（sklearn wrapper vs Booster）の変更であり、LightGBM 固有の下流コードに影響する。
- **`predict()` / `predict_proba()` / `predict_raw()` の shape 契約**: 変更なし。同一の入出力 shape を維持する。
- **`importance()` の shape 契約**: 変更なし。
- **Persistence**: joblib による `LGBMAdapter` のシリアライズ。`lgb.Booster` の `model_to_string()` / `model_from_string()` による保存・復元が必要。ただし `format_version=1` の互換性を維持するため、既存の保存済みモデル（sklearn wrapper ベース）のロードは引き続きサポートする必要がある。
- **SHAP**: `TreeExplainer` は `lgb.Booster` を直接受け取れるため互換性あり。

### Alternatives Considered

1. **テスト時に retry を追加して間欠エラーを許容する**
   - 不採用。根本原因が LightGBM の既知バグである以上、回避策を持つべき。ユーザー利用時にも影響する。
2. **`model_to_string()` 出力を post-fit で検証し、空の場合に再学習する**
   - 不採用。`LightGBMError` は `model_from_string()` 内部で raise されるため、post-fit 検証が間に合わない。
3. **`keep_training_booster=True` を sklearn wrapper に渡す**
   - 不採用。sklearn wrapper は `keep_training_booster` を外部パラメーターとして公開していない。
4. **LightGBM バージョンを制約する**
   - 不採用。4.3〜4.6 のすべてで再現するため、特定バージョンの除外では解決しない。

### Acceptance Criteria

- regression / binary / multiclass の全タスクで `lgb.train()` 経由の学習が動作する。
- `predict()` / `predict_proba()` / `predict_raw()` の出力 shape が移行前と同一である。
- `importance(kind="split")` / `importance(kind="gain")` が移行前と同一の結果を返す。
- `get_native_model()` が `lgb.Booster` を返す。
- `best_iteration` が正しく取得される。
- early stopping が正常に動作する（inner valid あり / なしの両方）。
- 学習履歴（`eval_history`）が cv_trainer / refit_trainer で正しく記録される。
- SHAP（`TreeExplainer`）が Booster 直接入力で動作する。
- 既存の persistence（export / load）が動作する。
- 既存テスト（782件）が回帰しない。
- notebook テスト（`tutorial_regression_tuning_lgbm.ipynb`）の間欠エラーが解消される。

### Migration

- `get_native_model()` の戻り値を `LGBMRegressor | LGBMClassifier` → `lgb.Booster` に変更。既存コードで `.booster_` 経由でアクセスしていた箇所は `.get_native_model()` 直接に変更する。
- `format_version=1` の既存保存モデルのロード互換は維持する（移行期間中は旧形式を検出して復元可能にする）。

---

## 2026-03-07: Model Facade の Mixin 分割

- ID: `H-0042`
- Status: `accepted`
- Scope: `Core | Architecture`
- Related: `BLUEPRINT.md §4.1, §19, 付録B`

### Context

`core/model.py` は Facade として assembly と delegation に徹しているが、1,451行・30+メソッドに肥大化している。メソッドは機能グループごとに明確に分かれており（plot系8メソッド、table/accessor系7メソッド、persistence系3メソッド等）、mixin による分割で可読性・保守性を改善できる。

公開 API（`Model` クラスのメソッドシグネチャ・戻り値）は一切変更しない。内部ファイル構成の変更のみ。

### Proposal

`core/model.py` を以下の mixin モジュールに分割し、`Model` クラスを多重継承で組み立てる。

1. **`core/_model_plots.py`** — `ModelPlotsMixin`
   - `residuals_plot()`, `roc_curve_plot()`, `calibration_plot()`, `probability_histogram_plot()`, `importance_plot()`, `plot_learning_curve()`, `plot_oof_distribution()`, `tuning_plot()`
   - 8メソッド、約300行

2. **`core/_model_tables.py`** — `ModelTablesMixin`
   - `evaluate_table()`, `residuals()`, `confusion_matrix()`, `importance()`, `params_table()`, `split_summary()`, `tuning_table()`
   - 7メソッド、約350行

3. **`core/_model_persistence.py`** — `ModelPersistenceMixin`
   - `export()`, `_resolve_export_path()`, `load()` (classmethod)
   - 3メソッド、約150行

4. **`core/model.py`** — `Model(ModelPlotsMixin, ModelTablesMixin, ModelPersistenceMixin)`
   - `__init__()`, `fit()`, `predict()`, `evaluate()`, `tune()`
   - プライベートヘルパー: `_build_splitter()`, `_build_inner_valid()`, `_make_inner_valid_factory()`, `_build_run_meta()`, `_require_fit()`, `_require_refit()`, `_load_data()`, `fit_result` プロパティ
   - 約600行

各 mixin は `self` の型を `Model` と仮定し、`_require_fit()` 等の共通ヘルパーを呼び出す。`TYPE_CHECKING` ガードで循環参照を回避する。

### Impact

- `lizyml/core/model.py`（分割元）
- `lizyml/core/_model_plots.py`（新規）
- `lizyml/core/_model_tables.py`（新規）
- `lizyml/core/_model_persistence.py`（新規）

### Compatibility

- **公開 API**: 変更なし。`from lizyml import Model` の利用者コードに影響しない。
- **import パス**: `lizyml.core.model.Model` は維持。mixin は `_` プレフィックスの非公開モジュール。
- **Persistence**: 変更なし（`format_version` 影響なし）。

### Alternatives Considered

1. **現状維持（分割しない）**
   - 不採用。1,451行は可読性の限界を超えており、今後のメソッド追加で悪化する。
2. **機能ごとに独立クラスに委譲（Composition パターン）**
   - 不採用。`model.plots.importance_plot()` のように API が変わり、破壊的変更になる。
3. **サブモジュールに分割し `__init__.py` で再 export**
   - 不採用。mixin の方がシンプルで、既存テストへの影響が最小。

### Acceptance Criteria

- `Model` の全既存テスト（861件）が回帰しない。
- `from lizyml import Model` および `from lizyml.core.model import Model` が引き続き動作する。
- 各 mixin ファイルが mypy strict でエラーゼロ。
- `model.py` が 700行以下に収まる。
- ruff lint / format がクリーン。

---

## 2026-03-07: テスト基盤の改善（conftest 集約・parametrize 強化・CI 拡張）

- ID: `H-0043`
- Status: `accepted`
- Scope: `Testing | CI`
- Related: `BLUEPRINT.md §18.1, §18.2`

### Context

テストスイート（861件、97%カバレッジ）は高品質だが、以下の保守性課題がある：

1. **ヘルパー関数の重複**: `_reg_df()`, `_bin_df()`, `_cfg()` 等のデータ生成ヘルパーが8+ファイルで重複定義されている。変更時に複数箇所の同期が必要。
2. **parametrize の活用不足**: タスク別（regression/binary/multiclass）テストが個別メソッドで書かれており、パラメタライズで統合できる余地がある。
3. **CI が develop PR 非対応**: 現状 main への PR のみで CI が実行される。develop への PR でも品質ゲートを回すべき。
4. **カバレッジ閾値なし**: `--cov-fail-under` が未設定で、カバレッジ回帰を検知できない。
5. **slow テストのローカルスキップ**: `@pytest.mark.slow` が定義されているが、ローカル開発時にデフォルトスキップする設定がない。
6. **optional dependency の "missing" テスト不足**: plotly / scipy の未インストール時パスが未テスト。

### Proposal

1. **conftest.py へのヘルパー集約**
   - `tests/conftest.py` に共通ヘルパー（`make_regression_df()`, `make_binary_df()`, `make_multiclass_df()`, `make_config()`）を定義する。
   - 各テストファイルのローカルヘルパーを conftest のヘルパーに置き換える。
   - データ生成は `seed` パラメーターを持ち、再現性を保証する。

2. **parametrize 強化**
   - E2E テスト（`test_e2e/`）でタスク横断のテストを `@pytest.mark.parametrize("task", ["regression", "binary", "multiclass"])` で統合する。
   - メトリクステストでタスク別の重複を削減する。

3. **CI の develop ブランチ対応**
   - `ci.yml` の `on.pull_request.branches` に `develop` を追加する。

4. **カバレッジ閾値の設定**
   - `pytest` 実行時に `--cov-fail-under=95` を追加する。

5. **slow テストのローカルデフォルトスキップ**
   - `pyproject.toml` の `[tool.pytest.ini_options]` に `addopts = "-m 'not slow'"` を追加する。
   - CI の main PR では明示的に `-m ""` で全テストを実行する。develop PR では slow を除外する。

6. **optional dependency の "missing" テスト追加**
   - plotly / scipy の未インストール時に `OPTIONAL_DEP_MISSING` エラーが発生することを検証するテストを追加する。

### Impact

- `tests/conftest.py`（大幅拡張）
- `tests/test_e2e/`（ヘルパー置換、parametrize 統合）
- `tests/test_config/`, `tests/test_training/`, `tests/test_tuning/` 等（ヘルパー置換）
- `.github/workflows/ci.yml`（develop 追加、`--cov-fail-under`）
- `pyproject.toml`（`addopts` 追加）
- `tests/test_plots/`, `tests/test_calibration/`（optional dep テスト追加）

### Compatibility

- **公開 API**: 変更なし。テスト基盤のみの変更。
- **テスト結果**: 既存テストの pass/fail は変わらない（リファクタリングのみ）。
- **CI**: develop PR でもゲートが走るようになる（追加のみ、既存動作に影響なし）。

### Alternatives Considered

1. **conftest を tests/ 直下ではなくサブディレクトリごとに配置**
   - 部分採用。共通ヘルパーは `tests/conftest.py`、サブディレクトリ固有のフィクスチャはサブディレクトリの `conftest.py` に配置する。
2. **pytest-lazy-fixture 等のプラグイン導入**
   - 不採用。依存を増やさず、標準の `conftest.py` + `pytest.fixture` で十分。

### Acceptance Criteria

- 共通ヘルパー（`_reg_df` 等）の重複定義が `tests/conftest.py` に集約され、各テストファイルからローカル定義が除去される。
- 全テスト（861件以上）が回帰しない。
- CI が `develop` ブランチへの PR でも実行される。
- カバレッジが 95% 未満の場合に CI が失敗する。
- `uv run pytest` でローカル実行時に slow テストがスキップされる。
- plotly / scipy の未インストール時テストが追加される。
- mypy / ruff がクリーン。

---

## 2026-03-09: Calibration CV の splitter 統一（BLUEPRINT 不一致の解消）

- ID: `H-0044`
- Status: `accepted`
- Scope: `Calibration | Split | Leakage`
- Related: `BLUEPRINT.md §10.1, §10.4, §10.5, §12.1`, `PLAN.md Phase 26`

### Context

BLUEPRINT §10.1 では「Splitter は外側 CV / early stopping / calibration で共通利用する」と定義されている。一方、現行実装の calibration cross-fit は `KFold` 固定で分割しており、`split.method` が `group_kfold` / `time_series` / `purged_time_series` / `group_time_series` の場合でも、group/time 制約を継承していない。

この不一致により、仕様上は守るべき分割境界（group overlap 禁止、時系列順、purge/embargo）が calibration 段階で崩れる余地がある。

### Proposal

1. calibration cross-fit の分割生成を `split.method` ベースに統一する。
   - `split.method` の family（kfold/stratified/group/time/purged/group_time）を calibration でも使用する。
   - fold 数のみ `calibration.n_splits` で上書きできるようにする。
2. `calibration.n_splits` と `split.n_splits` は独立値として維持する（一致必須にはしない）。
3. calibration 分割は splitters IF 経由で生成し、`KFold` 直接依存を廃止する。
4. `fit_result.splits.calibration` には実際に使用した calibration split を必ず保存する。
5. group/time 系で必要な補助情報（`groups`、時系列ソート後の行順）を calibration 分割にも適用する。
6. 分割不能（例: `n_splits` 過大、group 数不足、時系列条件不成立）は明示的なエラーで失敗させる。

### Impact

- `lizyml/calibration/cross_fit.py`（分割生成責務の見直し）
- `lizyml/core/model.py`（calibration 分割生成・引き渡し）
- `lizyml/core/_model_factories.py`（calibration splitter 構築ヘルパー追加）
- `tests/test_calibration/`（split.method 別の契約テスト追加）
- `tests/test_e2e/test_leakage_all.py`（group/time 境界の回帰防止テスト追加）

### Compatibility

- 公開 Config 形式は維持（`calibration.method`, `calibration.n_splits` は変更なし）。
- `split.method` が group/time 系の既存ユーザーは、calibration 分割の挙動が「ランダムKFold」から「split.method 準拠」に変わるため、`calibrated_oof` と関連メトリクス値が変化しうる。
- Artifacts shape / `format_version` 変更は不要（`splits.calibration` は既存フィールド内）。

### Alternatives Considered

1. 現行実装に合わせて BLUEPRINT を「calibration は KFold 固定」に修正する。
   - 不採用。BLUEPRINT の split/leakage 方針（outer/inner/calibration 一貫性）と矛盾するため。
2. `calibration.split_method` を新設して outer split と切り離す。
   - 不採用。公開 Config 拡張が必要で複雑化が大きい。まずは既存 `split.method` 継承で整合させる。
3. `calibration.n_splits` を廃止して `split.n_splits` に強制統一する。
   - 不採用。校正CVの分解能を独立に調整したい需要があるため。

### Acceptance Criteria

- `split.method="kfold"` で calibration 有効時、`len(splits.calibration) == calibration.n_splits` になる。
- `split.method="stratified_kfold"` で calibration 各 fold のラベル分布が極端に崩れない（層化分割として成立）。
- `split.method="group_kfold"` で calibration 各 fold に group overlap がない。
- `split.method="time_series"` で calibration 各 fold が時系列順（train < valid）を満たす。
- `split.method="purged_time_series"` で calibration 各 fold が `purge_gap + embargo` を満たす。
- `split.method="group_time_series"` で calibration 各 fold が group/time 境界を満たす。
- `split.n_splits != calibration.n_splits` のケースで outer と calibration が独立に動作する。
- 既存の leakage テスト（`cross-fit OOF != c_final`）が回帰しない。

### Decision

- Date: `2026-03-09`
- Result: `accepted`
- Notes: BLUEPRINT §10.5 / §12.1 に既に規定済みの契約を実装に反映する。`refactor/phase-26-calibration-split` ブランチで実施。

---

## 2026-03-09: evaluate_table の fold 列を OOF-per-fold に変更

- ID: `H-0045`
- Status: `accepted`
- Scope: `Evaluation | Public API | Contracts`
- Related: `BLUEPRINT.md §6.3, §7.1, §13.2`, `PLAN.md Phase 27`

### Context

現行の `evaluate_table()` は `fold_0..fold_N-1` に `if_per_fold`（train_idx 上の IF メトリクス）を表示している。実務上、fold ばらつきの確認は汎化性能（OOF）で行うことが多く、IF fold 値を `fold_n` として表示すると解釈ミスを誘発しやすい。

### Proposal

1. Evaluator の raw metrics に `oof_per_fold` を追加する。
   - 各 fold の `valid_idx` 上で metric を計算した dict の list（長さ = outer n_splits）。
2. `evaluate_table()` の `fold_0..fold_N-1` は `oof_per_fold` を表示する。
3. 既存の `if_mean` / `if_per_fold` は互換性のため維持する。
4. `evaluate_table()` の列意味を明記する。
   - `oof`: 全 OOF 集約値
   - `fold_n`: fold n の OOF（valid_idx）値
   - `if_mean`: IF 指標（参考値）

### Impact

- `lizyml/evaluation/evaluator.py`（`oof_per_fold` 追加）
- `lizyml/evaluation/table_formatter.py`（`fold_n` 参照元変更）
- `lizyml/core/types/fit_result.py`（metrics 契約 doc 更新）
- `tests/test_evaluation/`（契約テスト更新・追加）
- `tests/test_core/test_contracts.py`（metrics 階層ゴールデン更新）

### Compatibility

- `evaluate()` の raw 構造に `oof_per_fold` が追加される（後方互換な追加）。
- `evaluate_table()` の `fold_n` の意味は IF -> OOF に変わるため、値解釈は破壊的変更。
- `if_mean` / `if_per_fold` を維持することで、IF を参照する既存ユースケースは継続可能。
- Artifacts の top-level shape 変更はなく、`format_version` 変更は不要。

### Alternatives Considered

1. `fold_n` を維持し、`oof_fold_n` を別列追加する
   - 不採用。列が冗長になり、どちらを見るべきかが曖昧になるため。
2. IF 関連（`if_mean`, `if_per_fold`）を完全削除する
   - 不採用。既存利用との互換性影響が大きく、監査・デバッグ用途の需要が残るため。
3. `evaluate_table()` から fold 列を削除する
   - 不採用。fold ばらつき監視の要求を満たせないため。

### Acceptance Criteria

- `evaluate(metrics=[...])["raw"]` に `oof_per_fold` が含まれる。
- `oof_per_fold[i]` は `splits.outer[i][1]`（valid_idx）上で計算した metric と一致する。
- `evaluate_table()` の `fold_n` が `oof_per_fold[n]` を表示する。
- `if_mean` と `if_per_fold` は従来どおり計算・取得できる。
- 既存の OOF/calibration 契約テストが回帰しない。

### Decision

- Date: `2026-03-09`
- Result: `accepted`
- Notes: BLUEPRINT §7.1 / §13.2 に既に規定済みの契約を実装に反映する。`refactor/phase-27-oof-per-fold` ブランチで実施。

---

## 2026-03-09: 評価・可視化 API の IF/OOF 目的分類の明文化

- ID: `H-0046`
- Status: `accepted`
- Scope: `Evaluation | Plots | Public API | Contracts`
- Related: `BLUEPRINT.md §13.4 (新規)`, `PLAN.md Phase 28`

### Context

`evaluate_table()` の fold 列を OOF に変更（H-0045）した際、他の可視化・テーブル API にも IF（train_idx）と OOF（valid_idx）が混在していることが判明した。各 API が「診断目的（IF: 過学習検知）」と「汎化監視目的（OOF: モデル評価）」のどちらを主目的とするか、BLUEPRINT に正式な分類がない。

### Proposal

1. BLUEPRINT §13 に「評価・可視化 API の目的分類」サブセクション（§13.4）を追加する。
2. 既存 API を以下の 3 カテゴリに分類し、各 API のデータソースを明記する:
   - **汎化監視（OOF 優先）**: `evaluate_table()`（fold 列 = OOF）、`evaluate()`
   - **診断（IF + OOF 比較）**: `roc_curve_plot()`（IS/OOS）、`confusion_matrix()`（is/oos）、`residuals_plot()`（IS/OOS）
   - **学習過程監視**: `plot_learning_curve()`（train/valid loss）
3. 分類の原則: IS(In-Sample) = IF(train_idx) 集約値、OOS(Out-of-Sample) = OOF(valid_idx) 値。
4. 既存 API の挙動自体は変更しない（仕様の明文化のみ）。

### Impact

- `BLUEPRINT.md §13`（新規 §13.4 追加）
- 実装変更なし（明文化のみ）

### Compatibility

- 既存 API の挙動変更なし。
- ドキュメント・仕様の補足のみ。

### Alternatives Considered

1. 全 API を OOF のみに統一する
   - 不採用。IS/OOS 比較は過学習検知に有用であり、診断 API として残す価値がある。
2. 分類を BLUEPRINT に書かず、docstring のみで管理する
   - 不採用。API の目的が仕様として固定されないと、将来の変更で一貫性が崩れるリスクがある。

### Acceptance Criteria

- BLUEPRINT §13.4 に API 目的分類テーブルが追加される。
- 各 API のデータソース（IF/OOF/両方）が明記される。
- 既存テストが回帰しない（実装変更なし）。

### Decision

- Date: `2026-03-09`
- Result: `accepted`
- Notes: BLUEPRINT §13.4 に API 目的分類テーブルを追加済み。Phase 28 で確定。

---

## 2026-03-14: Isotonic Calibration LightGBM パラメーター強化

- ID: `H-0047`
- Status: `accepted`
- Scope: `Calibration | Internal`
- Related: `BLUEPRINT.md §12.2, §14.2.1`

### Context

現在の `IsotonicCalibrator` は最小限のデフォルトパラメーター（`n_estimators=200`, `max_depth=3`, `learning_rate=0.05`）のみで Early Stopping がなく、過学習リスクがある。また `LGBMRegressor`（sklearn wrapper）を使用しており、H-0041 で決定した Booster API（`lgb.train()`）統一方針と不整合がある。

### Proposal

1. `LGBMRegressor` → `lgb.train()`（Booster API）に移行する（H-0041 準拠）。
2. デフォルトパラメーターを以下に強化する（ユーザーは `calibration.params` で上書き可能）:
   ```python
   _ISOTONIC_DEFAULTS = {
       "objective": "binary",
       "metric": "binary_logloss",
       "monotone_constraints": [1],          # 常に強制（上書き不可）
       "monotone_constraints_method": "advanced",
       "num_leaves": 7,
       "max_depth": 3,
       "min_data_in_leaf_ratio": 0.01,       # fit 時に絶対値に解決
       "learning_rate": 0.03,
       "lambda_l2": 5.0,
       "min_gain_to_split": 0.0,
       "feature_fraction": 1.0,
       "bagging_fraction": 1.0,
       "bagging_freq": 0,
   }
   ```
3. `num_boost_round=1000` + Early Stopping（`patience=100`）を導入する。
4. Early Stopping 用 validation: calibration 学習データから 10% をランダムサンプリングする（`validation_ratio=0.1`, `seed=42` デフォルト、ユーザー上書き可能）。
5. `min_data_in_leaf_ratio` は fit 時に `min_data_in_leaf = max(1, ceil(n_train * ratio))` に解決する。
6. `objective="binary"` の Booster API predict は raw score を返すため、sigmoid 適用 + `np.clip(0, 1)` で確率に変換する。
7. calibration データが少数（< 20 行）の場合は Early Stopping を無効化して全データで学習する。

### Impact

- `lizyml/calibration/isotonic.py` — 実装変更（Booster API 移行 + パラメーター強化）
- `tests/test_calibration/test_isotonic_calibration.py` — テスト更新・追加

### Compatibility

- CalibrationResult の shape/contract は不変。
- 数値結果はデフォルトパラメーター変更により変わる。
- 公開 API（`CalibrationConfig`）の変更なし。既存の `calibration.params` dict で上書き可能。

### Alternatives Considered

1. sklearn wrapper のまま Early Stopping だけ追加する
   - 不採用。H-0041 の Booster API 統一方針と不整合が残る。
2. `IsotonicRegression`（sklearn）に置き換える
   - 不採用。LightGBM の単調制約のほうが柔軟であり、BLUEPRINT §12.2 の設計意図に合致する。

### Acceptance Criteria

- `IsotonicCalibrator` が `lgb.train()` を使用している。
- デフォルトパラメーターが Proposal 通りに設定されている。
- Early Stopping（patience=100）が機能し、1000 round 前に停止する。
- 内部 validation split（10%）が seed 固定で再現可能。
- ユーザーが `calibration.params` でデフォルトを上書きできる。
- `monotone_constraints=[1]` が常に強制される。
- 出力が [0, 1] 範囲、単調性を維持。
- 少サンプル（< 20 行）で Early Stopping が自動無効化される。
- 全テストが pass。

### Decision

- Date: `2026-03-14`
- Result: `accepted`
- Notes: Booster API 移行、デフォルトパラメーター強化、Early Stopping + 内部 validation split を実装済み。BLUEPRINT §12.2 に詳細を追加。Phase 29 で確定。

---

## 2026-03-14: Tuning Progress Callback

- ID: `H-0048`
- Status: `accepted`
- Scope: `Public API | Tuning`
- Related: `BLUEPRINT.md §4.1, §11`

### Context

`tune()` 実行時は trial 数が多いと待ち時間が長くなるが、進行状況を外部ツール（Widget 等）に通知する手段がない。外部ツール開発者向けに、trial ごとの進捗情報をリアルタイムで提供するコールバック API が必要。

### Proposal

1. `TuneProgressInfo` frozen dataclass を追加する:
   ```python
   @dataclass(frozen=True)
   class TuneProgressInfo:
       current_trial: int        # 現在の trial 番号（1-indexed）
       total_trials: int         # 全 trial 数
       elapsed_seconds: float    # 経過時間（秒）
       best_score: float | None  # これまでの最良スコア（None = まだ complete なし）
       latest_score: float | None  # 直近 trial のスコア（None = fail/pruned）
       latest_state: str         # "complete" | "pruned" | "fail"
   ```
2. `TuneProgressCallback` 型エイリアスを追加する:
   ```python
   TuneProgressCallback = Callable[[TuneProgressInfo], None]
   ```
3. `Tuner.__init__` に `progress_callback: TuneProgressCallback | None = None` パラメーターを追加する。
4. `Model.tune()` に `progress_callback: TuneProgressCallback | None = None` パラメーターを追加する。
5. Optuna の `study.optimize(callbacks=[...])` を活用して、各 trial 完了時に `TuneProgressInfo` を構築して `progress_callback` に渡す。
6. `progress_callback` 内で例外が発生した場合は catch して warning に変換し、tuning を中断させない。
7. `TuneProgressInfo` と `TuneProgressCallback` を `lizyml/__init__.py` の公開面に追加する。

### Impact

- `lizyml/core/types/tuning_result.py` — `TuneProgressInfo` dataclass + `TuneProgressCallback` 型追加
- `lizyml/tuning/tuner.py` — コールバック統合
- `lizyml/core/model.py` — `Model.tune()` シグネチャ変更
- `lizyml/__init__.py` — 公開面追加
- `tests/test_tuning/test_tuning_progress.py` — 新規テスト

### Compatibility

- 後方互換。`progress_callback` はデフォルト `None` で既存動作に影響なし。
- `TuningResult` の shape/contract は不変。

### Alternatives Considered

1. ログベースの進捗報告（`logging` 出力のみ）
   - 不採用。外部ツールがログをパースする必要があり、構造化されたコールバックのほうが使いやすい。
2. イベントバス / Pub-Sub パターン
   - 不採用。現時点ではコールバック 1 つで十分であり、過度に複雑化する。
3. `tqdm` / progress bar の表示
   - 不採用。CUI 向けの表示であり、Widget 等の外部ツールには不適切。コールバックのほうが汎用的。

### Acceptance Criteria

- `Model.tune(progress_callback=fn)` でコールバックが各 trial 完了時に呼ばれる。
- `TuneProgressInfo` の各フィールドが正しい値を持つ。
- `current_trial` が 1 から n_trials まで順に増加する。
- `elapsed_seconds >= 0` である。
- `best_score` が最初の complete trial 以降は `None` でない。
- `progress_callback=None`（デフォルト）で既存動作に影響なし。
- コールバック内例外が tuning を中断させない。
- `TuneProgressInfo` と `TuneProgressCallback` が `from lizyml import ...` で import 可能。
- 全テストが pass。

### Decision

- Date: `2026-03-14`
- Result: `accepted`
- Notes: `TuneProgressInfo` / `TuneProgressCallback` を定義し、`Tuner` / `Model.tune()` にコールバック統合を実装済み。BLUEPRINT §4.1 / §11.4 に記載。Phase 29 で確定。

---

## 2026-03-14: multiclassova 使用時の確率正規化

- ID: `H-0049`
- Status: `accepted`
- Scope: `Evaluation`
- Related: `BLUEPRINT.md §8, lizyml/evaluation/evaluator.py`

### Context

`objective="multiclassova"` で学習した場合、LightGBM の `booster.predict()` は各クラスに独立した sigmoid を適用するため、行ごとの合計が 1.0 にならない。sklearn の `roc_auc_score(multi_class="ovr")` は合計 1.0 をハードバリデーションしており、非正規化の出力を渡すと `ValueError` が発生する。`brier` / `logloss` も確率分布を前提とするため値が不正確になる。

起票元: LizyML-Widget (multiclass Fit で AUC 評価エラー)。

### Proposal

- `predict_proba()` の契約は変更しない（生の sigmoid 出力を返し続ける）。
- 評価パイプラインの責務として、`_pred_for_metric()` 内で `needs_proba=True` かつ `multiclass` かつ 2D の場合に行正規化を適用する。
- `_normalize_multiclass_proba()` を新設し、行ごとに `pred / row_sums` で正規化する（all-zero 行のゼロ除算ガード付き）。
- `multiclass` (softmax) の場合は既に合計 ≈ 1.0 のため冪等（no-op）。

### Impact

- 変更対象: `lizyml/evaluation/evaluator.py` の `_pred_for_metric()` 1 関数のみ。
- `predict_proba()` / `predict()` / `BaseMetric` Protocol / 個別メトリクスクラス / `_TASK_METRICS` は変更しない。

### Compatibility

- 後方互換。`multiclass` (softmax) では正規化が冪等のため出力値は実質不変。
- `multiclassova` 使用時のメトリクス値が修正される（バグ修正の性質）。

### Alternatives Considered

1. `predict_proba()` で正規化する（案 A）
   - 不採用。生の sigmoid 出力を保持する要件がある（LizyML-Widget 側で生値を使用）。
2. `BaseMetric` に `needs_normalized_proba` 属性を追加する（案 C）
   - 当初不採用としたが、レビューにより **採用に変更**（`needs_simplex` として実装）。
   - `auc_pr` / `brier` は per-class OvR 計算のため行正規化するとクラス内ランキングが変わる。
   - simplex が必要なメトリクス（`auc`, `logloss`）のみ正規化すべき。

### Acceptance Criteria

- `multiclassova` の非正規化出力で `roc_auc_score` がエラーなく動作する。
- softmax 出力は `assert_allclose` で実質不変。
- `needs_simplex=True` メトリクス（AUC, LogLoss）のみ行正規化される。
- `needs_simplex=False` メトリクス（AUCPR, Brier）は raw 値を受け取る。
- all-zero 行でゼロ除算が発生しない。
- binary / regression の `_pred_for_metric` は影響を受けない。
- `needs_proba=False` のメトリクスは影響を受けない。
- 全テストが pass。

### Decision

- Date: `2026-03-14`
- Result: `accepted`
- Notes: Evaluator 層での行正規化（案 B）を採用。ただし正規化対象を `needs_simplex=True` メトリクスに限定（案 C を統合）。`BaseMetric.needs_simplex` をデフォルト `False` の concrete property として追加し、`AUC` / `LogLoss` のみ `True` にオーバーライド。per-class OvR メトリクス（AUCPR, Brier）は raw 値を受け取る。

---

## 2026-03-15: Smart Parameter 統一 & TrainComponents 導入

- ID: `H-0050`
- Status: `accepted`
- Scope: `Training | Tuning | Result`
- Related: `BLUEPRINT.md §5.3, §6.1, §6.2, §7.2, §11.2`

### Context

現状 `resolve_smart_params`（fit 用、`LGBMConfig` を受け取る）と `resolve_smart_params_from_dict`（tune 用、`dict` を受け取る）の 2 関数が存在し、対応する smart params の範囲が非対称（tune 版は `feature_weights` / `balanced` を未対応）。また `TuningResult.best_params` が flat dict であるため、tune → fit 時に smart params のカテゴリ区別が失われ、Config 側の固定値で上書きされてしまう問題がある。fit / tune で CVTrainer への組み立てロジックも重複しており、一貫性・保守性を損なっている。

### Proposal

1. **`resolve_smart_params` を dict ベースに統一**: 第 1 引数を `LGBMConfig` → `dict[str, Any]` に変更。`extract_smart_params(config: LGBMConfig) -> dict` ヘルパーを追加。`resolve_smart_params_from_dict` を削除。fit / tune で同一関数を使用する。

2. **`TuningResult` をカテゴリ別に変更**: `best_params`（flat dict）を `best_model_params` / `best_smart_params` / `best_training_params` に分割。互換性のため `best_params` を computed property（flat view）として残す。

3. **`TrainComponents` 導入**: パラメータ解決結果を保持する dataclass（`estimator_factory` / `sample_weight` / `ratio_resolver` / `inner_valid`）。`Model._build_train_components()` で構築し、CVTrainer と RefitTrainer に同一インスタンスを渡すことで一貫性を構造的に保証する。

4. **`Model.fit()` / `Model.tune()` の共通化**: 両者とも `_build_train_components()` を経由して CVTrainer を構築する。tune の各 trial は `_build_train_components(model_params=..., smart_params=...)` を呼び、fit と同じコードパスを通る。

5. **`Tuner` のシンプル化**: Tuner の責務を Optuna study 管理のみに縮小。`objective` クロージャは Model 側で構築して注入する。Tuner から LGBM 固有の import をすべて除去する。

6. **`Model._best_params` 削除**: `_tuning_result` からカテゴリ別に取得する。パラメータ優先順位: `Config defaults < tune best < fit() 引数`。

### Impact

- `lizyml/estimators/lgbm.py`: `resolve_smart_params` 引数変更、`extract_smart_params` 追加、`resolve_smart_params_from_dict` 削除
- `lizyml/core/types/tuning_result.py`: field 構成変更
- `lizyml/core/model.py`: `TrainComponents` 追加、`_build_train_components` / `_merge_params` 追加、fit() / tune() 書き換え、`_best_params` 削除
- `lizyml/tuning/tuner.py`: コンストラクタ縮小、LGBM 固有ロジック除去

- 変更しないもの: `CVTrainer` / `RefitTrainer` / `config/schema.py` / `search_space.py` のインターフェース

### Compatibility

- `TuningResult.best_params` は computed property として残すため、読み取り側は互換。ただし `TuningResult` のコンストラクタは変更される（`best_params` → `best_model_params` + `best_smart_params` + `best_training_params`）。
- `Tuner` のコンストラクタは大幅に縮小されるが、内部 API のため外部互換性は影響なし。
- `resolve_smart_params_from_dict` は削除されるが、内部 API のため外部互換性は影響なし。

### Alternatives Considered

1. `TuningResult.best_params` に `overrides` 引数を追加する（fit 側で overrides 適用）
   - 不採用。カテゴリの区別が曖昧なまま残り、将来のアルゴリズム追加時に同じ問題が再発する。
2. EstimatorBuilder パターン（B案）を先に導入する
   - 不採用（段階的に実施）。tune → fit の smart params 問題を先に解決し、クリーンな状態で B案を検討する。

### Acceptance Criteria

- `resolve_smart_params_from_dict` が削除され、fit / tune が同一の `resolve_smart_params(dict, ...)` を使用している。
- `TuningResult` が `best_model_params` / `best_smart_params` / `best_training_params` を持ち、`best_params` property が flat view を返す。
- `_build_train_components()` が CVTrainer と RefitTrainer に同一の factory / resolver を提供している。
- tune() の各 trial が `_build_train_components()` を経由して CVTrainer を構築している。
- `Tuner` が LGBM 固有の import を持たない。
- tune → fit で smart params（`num_leaves_ratio` 等）が正しく引き継がれるテストが存在する。
- 既存テスト（910件）がすべて pass する。

### Decision

- Date: `2026-03-15`
- Result: `accepted`
- Notes: 議論の結果、「Config → Tune → Fit の一連のフローで同一コードパスを通る」設計を優先。B案（EstimatorBuilder）は本 Proposal 完了後に段階的に検討する。

---

## 2026-03-16: デッドコード削除と Foundation 整理

- ID: `H-0051`
- Status: `accepted`
- Scope: `Architecture | Internal`
- Related: `BLUEPRINT.md §2, §19, ARCHITECTURE.md`

### Context

アーキテクチャレビューの結果、以下のデッドコードと構造上の問題が発見された:
1. 本番コードで未使用のクラス/モジュールが複数存在する（`TargetTransformer`, `SplitPlan`, `HoldoutSplitter`, 未使用 Spec 群, `import_optional.py`）。
2. `EstimatorRegistry` / `SplitterRegistry` が `@register` デコレータで書き込みされるが `.get()` が呼ばれない（書き込み専用）。`MetricRegistry` / `CalibratorRegistry` は正当な lookup がある。
3. `types/` が `data/` に依存している（`DataFingerprint` の import）。ARCHITECTURE.md で定義した Layer 0 → Layer 1 の逆依存。
4. `splitters/` ↔ `specs/` の循環依存が存在する。

これらは ARCHITECTURE.md で定義した 5 層カテゴリアーキテクチャ（Layer 0: Foundation → Layer 1: Leaf → Layer 2: Composition → Layer 3: Optional → Layer 4: Facade）の前提条件である「DAG 構造」と「各 Leaf カテゴリの独立性」に違反する。

### Proposal

1. **デッドコード削除**:
   - `lizyml/features/transformers/target_transformer.py` を削除（完全未使用スタブ）
   - `lizyml/core/specs/split_plan.py` を削除（`_model_factories` に完全置換済み）
   - `lizyml/splitters/holdout.py` を削除（`SplitPlan` 経由のみ、他に呼び出し元なし）
   - `lizyml/core/specs/export_spec.py` を削除（未使用）
   - `lizyml/core/specs/training_spec.py` の `TrainingSpec` / `EarlyStoppingSpec` / `InnerValidSpec` を削除（未使用）
   - `lizyml/core/specs/calibration_spec.py` を削除（未使用）
   - `lizyml/core/specs/tuning_spec.py` を削除（未使用）
   - `lizyml/config/loader.py` の `config_to_split_spec` / `config_to_training_spec` / `config_to_tuning_spec` / `config_to_calibration_spec` / `config_to_export_spec` / `config_to_problem_spec` / `config_to_feature_spec` を削除（本番で未使用。`ProblemSpec` / `FeatureSpec` は `model.py` で直接構築しているため変換関数は不要）
   - `lizyml/utils/import_optional.py` を削除（全箇所がインライン try/except を使用）
   - `lizyml/splitters/__init__.py` の `_build_splitter(SplitSpec)` を削除（`_model_factories` の Config ベースと重複）

2. **書き込み専用 Registry の削除**:
   - `EstimatorRegistry` の `@register` デコレータを `LGBMAdapter` から除去し、`EstimatorRegistry` クラスを `registries.py` から削除
   - `SplitterRegistry` の `@register` デコレータを全 Splitter クラスから除去し、`SplitterRegistry` クラスを `registries.py` から削除
   - `MetricRegistry` / `CalibratorRegistry` は `.get()` 呼び出しがあるため維持

3. **DataFingerprint の移動**:
   - `lizyml/data/fingerprint.py` の `DataFingerprint` dataclass を `lizyml/core/types/artifacts.py` に移動
   - `compute()` 関数は `lizyml/data/fingerprint.py` に残す（`data/` が Foundation の型を返す形になり、逆依存が解消される）

4. **循環依存の解消**:
   - `specs/split_plan.py` 削除により `splitters/ → specs/` → `splitters/` の循環が自動解消

### Impact

- 削除対象はすべて本番コードで未使用（テストのみで使用）。公開 API・Result shape・format_version に影響なし。
- `ProblemSpec` / `FeatureSpec` / `SplitSpec` は維持（`ProblemSpec` / `FeatureSpec` は `model.py` で使用中、`SplitSpec` は将来の Spec-based パスの可能性を残す）。

### Compatibility

- 公開 API の変更なし。内部モジュールの削除のみ。
- `DataFingerprint` の import パスが `lizyml.data.fingerprint.DataFingerprint` → `lizyml.core.types.artifacts.DataFingerprint` に変更されるが、内部 API のため外部互換性は影響なし。

### Alternatives Considered

1. デッドコードを残し、将来の実装に備える
   - 不採用。Spec 層は `_model_factories` の直接 Config パスに完全に置き換えられており、復活の見込みがない。デッドコードの存在は保守性を下げ、アーキテクチャの理解を妨げる。

### Acceptance Criteria

- 削除対象ファイルがリポジトリに存在しないこと
- `splitters/ ↔ specs/` の循環依存が解消されていること
- `types/` から `data/` への依存が解消されていること（`DataFingerprint` が `types/artifacts.py` に存在すること）
- `EstimatorRegistry.get()` / `SplitterRegistry.get()` がコードベースに存在しないこと
- 既存テスト（962件）から削除対象のテストを除いた全テストが pass すること
- `ruff check` / `mypy` がクリーンであること

---

## 2026-03-16: Layer 間依存の浄化

- ID: `H-0052`
- Status: `accepted`
- Scope: `Architecture | Training | Evaluation`
- Related: `BLUEPRINT.md §2, §6.2, §6.3, §13.2, §14, §19, ARCHITECTURE.md`

### Context

ARCHITECTURE.md で定義した 5 層カテゴリアーキテクチャにおいて、以下の Layer ルール違反が存在する:

1. **training/ → evaluation/**: `cv_trainer.py` が `evaluation/oof.py` の `fill_oof`, `get_fold_pred`, `init_oof` を import している。これらは OOF アセンブリの ndarray ユーティリティであり、metric 計算（Evaluator の責務）とは無関係。Layer 2 の同層間依存。
2. **evaluation/ → calibration/**: `evaluator.py` が `CalibrationResult` を `isinstance` チェックし、calibrated metrics を直接組み立てている。Layer 2 → Layer 1 への不要な依存。Evaluator の責務は「raw predictions + y_true → metrics dict」であるべき。
3. **estimators/ → config/**: `lgbm.py` の `extract_smart_params(LGBMConfig)` が `config/schema.py` の `LGBMConfig` を直接参照している。Layer 1 の Leaf 間依存（Leaf カテゴリは互いに依存してはならない）。

### Proposal

1. **OOF ヘルパーを training/ に移動**:
   - `evaluation/oof.py` の `fill_oof`, `get_fold_pred`, `get_fold_raw`, `init_oof` を `training/oof_assembly.py`（新規）に移動
   - `cv_trainer.py` の import を `from lizyml.training.oof_assembly import ...` に変更
   - `evaluation/oof.py` は空にするか、後方互換の re-export のみ残す（内部 API のため即削除も可）

2. **Evaluator から calibration 依存を除去**:
   - `evaluator.py` の `evaluate()` は raw predictions のみを受け取り、`{"raw": {...}}` のみを返す
   - calibrated metrics の組み立ては **Facade**（`model.py` の `fit()` 内）が担当する: calibrated OOF を `evaluator.evaluate()` に別途渡して結果を `{"calibrated": {...}}` として統合する
   - `evaluator.py` から `CalibrationResult` の import と `isinstance` チェックを除去

3. **estimators/ から config/ 依存を除去**:
   - `extract_smart_params(LGBMConfig) -> dict` を `estimators/lgbm.py` → Facade（`model.py` または `_model_factories.py`）に移動
   - `lgbm.py` の `resolve_smart_params` / `resolve_ratio_params` は既に dict ベースのため変更不要
   - `lgbm.py` から `from lizyml.config.schema import LGBMConfig` を除去

### Impact

- **training/**: `cv_trainer.py` の import パスのみ変更。ロジックは同一。
- **evaluation/**: `Evaluator.evaluate()` の返り値から `"calibrated"` キーが消える。calibrated metrics は Facade 側で追加される。最終的な `FitResult.metrics` の shape は変更なし。
- **estimators/**: `lgbm.py` から `LGBMConfig` 依存が消える。`resolve_smart_params` は dict を受け取るため影響なし。
- **Facade**: `model.py` の `fit()` に calibrated metrics 組み立てロジックが追加される（Evaluator を2回呼ぶ形）。`extract_smart_params` の呼び出し元が移動する。

### Compatibility

- 公開 API の変更なし。`FitResult.metrics` の最終 shape は不変。
- `Evaluator.evaluate()` の返り値 shape が変更されるが、Evaluator は内部 API。

### Alternatives Considered

1. `evaluation/oof.py` を `core/` の共有ユーティリティに移動する
   - 不採用。OOF アセンブリは training loop 固有のロジックであり、Foundation に置く正当性がない。
2. Evaluator に calibrated_oof を引数で渡す（Evaluator 内で `{"calibrated": ...}` を生成）
   - 候補として残す。ただし Evaluator が CalibrationResult 型を知らなくても済む設計が優先。

### Acceptance Criteria

- `training/` から `evaluation/` への import が存在しないこと
- `evaluation/` から `calibration/` への import が存在しないこと
- `estimators/` から `config/` への import が存在しないこと
- `FitResult.metrics` の最終 shape が不変であること（`{"raw": {...}, "calibrated": {...}}` 構造を維持）
- 既存テスト（962件）がすべて pass すること
- カテゴリ間依存分析スクリプトで Layer ルール違反がゼロであること

---

## 2026-03-16: EstimatorProvider 導入（マルチアルゴリズム準備）

- ID: `H-0053`
- Status: `accepted`
- Scope: `Architecture | Estimators | Public API (internal)`
- Related: `BLUEPRINT.md §2, §14, §14.1, §19, §20, ARCHITECTURE.md`
- Depends: `H-0051, H-0052`

### Context

H-0050 で `_build_train_components` / `_merge_params` により fit/tune の共通化を達成した。しかし `model.py` は依然として LGBM に直接依存している:

```python
from lizyml.estimators.lgbm import LGBMAdapter, extract_smart_params, ...
isinstance(model_cfg, LGBMConfig)  # _merge_params 内 ×2
LGBMAdapter(task=..., params=...)  # make_estimator 内
default_space(cfg.task)            # tune() 内
```

EntityEmbedding 等の新アルゴリズムを追加するとき、`model.py` に `isinstance(model_cfg, EntityEmbeddingConfig)` を追加し続ける設計は持続可能でない。ARCHITECTURE.md の Layer ルール「Facade 以外の Layer は具象クラスを型ディスパッチしない」に違反する。

### Proposal

1. **EstimatorProvider protocol を定義** (`estimators/provider.py`):

   ```python
   class EstimatorProvider(Protocol):
       def extract_model_params(self, model_cfg: Any) -> dict[str, Any]: ...
       def extract_smart_params(self, model_cfg: Any) -> dict[str, Any]: ...
       def resolve_smart_params(
           self, smart: dict, effective: dict, n_rows: int,
           feature_names: list[str], y: Series, task: str,
       ) -> tuple[dict[str, Any], ndarray | None]: ...
       def build_ratio_resolver(
           self, smart: dict,
       ) -> Callable[[int], dict[str, Any]] | None: ...
       def build_estimator_factory(
           self, task: str, params: dict, n_classes: int | None,
           early_stopping_rounds: int | None, seed: int,
       ) -> Callable[[], BaseEstimatorAdapter]: ...
       def build_pipeline_factory(self) -> Callable[[], BaseFeaturePipeline]: ...
       def default_space(self, task: str) -> list[SearchDim]: ...
       def default_fixed_params(self, task: str) -> dict[str, Any]: ...
   ```

2. **LGBMProvider を実装** (`estimators/lgbm/provider.py`):
   - 既存の `extract_smart_params`, `resolve_smart_params`, `resolve_ratio_params`, `default_space`, `default_fixed_params` を LGBMProvider のメソッドとして再配置
   - `build_estimator_factory` で `LGBMAdapter` を生成
   - `build_pipeline_factory` で `NativeFeaturePipeline` を返す

3. **Provider の解決** (`estimators/registry.py` または `_model_factories.py`):
   - `get_provider(model_cfg: ModelConfig) -> EstimatorProvider`
   - `ModelConfig` の `name` フィールドで dispatch（`"lgbm"` → `LGBMProvider`）
   - この dispatch は Facade 層（`_model_factories.py`）に置く

4. **model.py の書き換え**:
   - `from lizyml.estimators.lgbm import ...` を除去
   - `_merge_params` / `_build_train_components` / `tune` を provider 経由に変更
   - `isinstance(model_cfg, LGBMConfig)` チェックを除去

5. **新アルゴリズム追加時の手順** (目標):
   - `estimators/<name>/` に adapter + provider + config を作成
   - `config/schema.py` の `ModelConfig` union に追加
   - `_model_factories.py` の provider dispatch に追加
   - **model.py の変更: ゼロ**

### Impact

- `model.py`: LGBM 直接 import を除去。provider 経由に書き換え。
- `estimators/lgbm.py`: 既存関数を `LGBMProvider` に再配置。関数自体のロジックは変更なし。
- `tuning/search_space.py`: `default_space` / `default_fixed_params` を LGBMProvider に移動。`parse_space` / `suggest_params` / `split_by_category` は汎用のため tuning/ に残す。

### Compatibility

- 公開 API の変更なし。`Model.fit()` / `Model.tune()` / `Model.predict()` のシグネチャは不変。
- `EstimatorProvider` は内部 protocol。ユーザーが直接触れることはない。
- `LGBMAdapter` の import パスは `lizyml.estimators.lgbm.LGBMAdapter` を維持（`__init__.py` で re-export）。

### Alternatives Considered

1. Abstract base class (`ABCEstimatorProvider`) を使う
   - 不採用。Protocol の方が structural subtyping で柔軟。optional dependency のアルゴリズム（torch 系）でクラス継承を強制しない。
2. Factory 関数群を module-level で定義し、dict dispatch する
   - 不採用。Protocol の方が型安全で、mypy でチェック可能。
3. 現状の `isinstance` dispatch を維持し、新アルゴリズムごとに `elif` を追加する
   - 不採用。Open-Closed Principle に違反し、model.py がアルゴリズム追加のたびに変更される。

### Acceptance Criteria

- `model.py` に `from lizyml.estimators.lgbm import` が存在しないこと
- `model.py` に `isinstance(model_cfg, LGBMConfig)` が存在しないこと
- `LGBMProvider` が `EstimatorProvider` protocol を満たすこと（mypy で検証）
- 新アルゴリズム追加のテンプレートとして `estimators/lgbm/` のディレクトリ構造がドキュメント化されていること
- 既存テスト（962件）がすべて pass すること
- tune → fit で smart params が正しく引き継がれるテストが維持されていること

---

## H-0054: EstimatorProvider 完全化 — 残存 LGBM 固有依存の排除

- ID: `H-0054`
- Status: `accepted`
- Scope: `Architecture | EstimatorProvider | Internal`
- Related: `BLUEPRINT.md §2.1, §2.2, §14.4, HISTORY.md H-0053`
- Depends: `H-0053`

### Background

H-0053 で `EstimatorProvider` protocol を導入し、`model.py` のゼロ LGBM import を達成した。
しかしアーキテクチャ監査の結果、Facade 周辺と Layer 2 に LGBM 固有の知識が残存しており、
2つ目のアルゴリズム（EntityEmbedding 等）追加時にクラッシュまたはサイレント不具合が発生する。

### Problem Statement

| # | 問題 | 影響度 | ファイル |
|---|---|---|---|
| 1 | `cv_trainer.py` / `refit_trainer.py` が `categorical_feature=cat_cols or "auto"` を直書き | HIGH — 非 LGBM アダプタで `TypeError` | `training/cv_trainer.py:257`, `training/refit_trainer.py:124` |
| 2 | `_model_tables.py:params_table()` が `isinstance(model_cfg, LGBMConfig)` + `booster.params` 前提 | HIGH — 非 LGBM で `AttributeError` | `core/_model_tables.py:235` |
| 3 | `_build_run_meta` に `"lightgbm": _ver("lightgbm")` ハードコード | HIGH — BLUEPRINT §2.2「model.py 変更ゼロ」違反 | `core/model.py:713` |
| 4 | `estimators/provider.py` が `tuning/search_space.py` の `SearchDim` を import | HIGH — L1→L2 逆依存 | `estimators/provider.py:20` |
| 5 | `model.py:tune()` が `est.early_stopping_rounds = esr` を属性直書き | MEDIUM — 異名アダプタで silent no-op | `core/model.py:452` |
| 6 | `shap_explainer.py` が `NativeFeaturePipeline` を直 import | MEDIUM — L3→L1 具象依存 | `explain/shap_explainer.py:133` |
| 7 | `model.py` 836 行（800 行上限超過） | LOW — 保守性 | `core/model.py` |
| 8 | テスト `make_config()` が `"lgbm"` ハードコード、`fit()` docstring が "LightGBM" | LOW — 拡張時の障壁 | `tests/_helpers.py:125`, `core/model.py:144` |

### Proposed Changes

#### Phase A: 構造修正（2つ目のアルゴリズム追加の前提条件）

**A1. `SearchDim` 型を Foundation に移動**
- `SearchDim`, `FloatDim`, `IntDim`, `CategoricalDim`, `DimCategory` を `core/types/search_dim.py` に移動
- `tuning/search_space.py` は `parse_space`, `suggest_params`, `split_by_category`（Optuna 依存のロジック）のみ残す
- `estimators/provider.py` の import を `core/types/search_dim.py` に変更
- 影響: L1→L2 逆依存が解消

**A2. `categorical_feature` をアダプタ契約に移動**
- `BaseEstimatorAdapter` に `set_categorical_features(cols: list[str] | None) -> None` メソッド追加（デフォルト no-op）
- `LGBMAdapter` でオーバーライドし、`fit()` 内で `categorical_feature` kwarg に変換
- `cv_trainer.py` / `refit_trainer.py` から `categorical_feature=` kwarg 削除
- `TrainComponents` に `categorical_features: list[str] | None` フィールド追加
- CVTrainer は `estimator.set_categorical_features(cat_cols)` を呼び、その後 `estimator.fit()` を呼ぶ

**A3. `EstimatorProvider` に `runtime_deps()` / `params_summary()` 追加**
- `runtime_deps(self) -> dict[str, str]`: アルゴリズム固有の依存パッケージとバージョンを返す
- `params_summary(self, model: BaseEstimatorAdapter, model_cfg: Any) -> list[dict[str, Any]]`: params_table 用のパラメータ行を返す
- `LGBMProvider` で実装（現在 `_model_tables.py` にあるロジックを移植）

**A4. `_model_tables.py` から LGBMConfig 依存除去**
- `params_table()` を `provider.params_summary()` 経由に書き換え
- `LGBMConfig` import 削除
- `booster.params.get(k)` 直接参照を削除

**A5. `_build_run_meta` から `"lightgbm"` ハードコード除去**
- `provider.runtime_deps()` を呼び、返り値を `deps_versions` にマージ
- provider を `_build_run_meta` の引数に追加

#### Phase B: 品質改善

**B1. `early_stopping_rounds` を Provider 経由に**
- `EstimatorProvider.build_estimator_factory()` に `early_stopping_rounds` パラメータは既にある
- `model.py:tune()` の属性直書き（`est.early_stopping_rounds = esr`）を、`provider.build_estimator_factory()` 再呼び出しに変更

**B2. `shap_explainer` の Pipeline 復元を Provider 経由に**
- `compute_shap_importance` の引数に `pipeline_factory: Callable[[], BaseFeaturePipeline]` を追加
- `NativeFeaturePipeline` 直 import を削除
- Facade（`_model_plots.py`）が `provider.build_pipeline_factory()` を渡す

**B3. `model.py` ヘルパー抽出**
- `_has_metric_content`, `_filter_metrics` を `core/_model_metrics.py` に抽出
- `model.py` を 800 行以内に

**B4. テスト・docstring 整備**
- `make_config()` に `model_name: str = "lgbm"` パラメータ追加
- `fit()` docstring の "LightGBM parameters" を "Model parameters" に変更
- `Evaluator` docstring から "calibrated" 言及を削除
- `lgbm/__init__.py` に `LGBMProvider` re-export 追加

### Compatibility

- 公開 API の変更なし。`Model.fit()` / `Model.tune()` / `Model.predict()` のシグネチャは不変。
- `BaseEstimatorAdapter` に `set_categorical_features()` 追加（デフォルト no-op、後方互換）。
- `EstimatorProvider` に `runtime_deps()` / `params_summary()` 追加（protocol 拡張、内部のみ）。
- `SearchDim` の import パスが `tuning.search_space` → `core.types.search_dim` に変更（内部のみ、公開 API に含まれない）。

### Alternatives Considered

1. `categorical_feature` を `fit()` の `**kwargs` に任せ続ける
   - 不採用。新アダプタで `TypeError` が起きるリスクが高く、L2 の estimator 非依存性が破れる。
2. `SearchDim` を `estimators/` に移動する（L1 内で完結）
   - 不採用。`SearchDim` は tuning 以外（将来の config validation 等）でも使われる可能性がある。Foundation に置く方が汎用的。
3. `params_summary()` を `BaseEstimatorAdapter` のメソッドにする
   - 不採用。アダプタは「学習と予測」に徹すべき。テーブル表示はプレゼンテーション層の関心で、Provider が適切。

### Acceptance Criteria

- `_model_tables.py` に `LGBMConfig` import が存在しないこと
- `cv_trainer.py` / `refit_trainer.py` に `categorical_feature` が存在しないこと
- `model.py` に `"lightgbm"` 文字列リテラルが存在しないこと
- `estimators/provider.py` に `tuning/` からの import が存在しないこと
- `shap_explainer.py` に `NativeFeaturePipeline` import が存在しないこと
- `model.py` が 800 行以内であること
- 全テスト pass（932 件）
- mypy clean（86 ファイル）

---

## H-0055: StratifiedGroupKFold の Config 接続

- ID: `H-0055`
- Status: `implemented`
- Scope: `Config | Splitters`
- Related: `BLUEPRINT.md §5, §10`

### 目的

`StratifiedGroupKFoldSplitter`（既に `splitters/group_kfold.py` に実装済み）を Config → Model パイプラインに接続する。グループ制約と層化分割を同時に必要とするユースケース（例: 顧客IDでグループ分割しつつクラスバランスを維持）を Config 経由で利用可能にする。

### 影響範囲

- `config/schema.py`: `StratifiedGroupKFoldConfig` 追加、`SplitConfig` union 拡張
- `config/loader.py`: エイリアス追加（`stratified-group-kfold` 等）
- `core/_model_factories.py`: `_build_splitter_for_method` dispatch 追加、`_resolve_auto_inner_valid` にエントリ追加
- `BLUEPRINT.md §5, §10`: ドキュメント更新

### 互換性

- 既存 Config は影響なし（discriminated union への追加は後方互換）
- `StratifiedGroupKFoldSplitter` クラス自体は変更なし

### 代替案

なし。Splitter は既に実装・テスト済みであり、Config 接続のみが不足している。

### 受け入れ基準

- `method: "stratified_group_kfold"` で Config → Model → fit が完走すること
- エイリアス（`stratified-group-kfold` 等）が正規化されること
- InnerValid auto-resolution で `group_holdout` が選択されること（group 制約を維持）
- 全テスト pass、mypy clean

---

## H-0056: テスト基盤の体系的補強

- ID: `H-0056`
- Status: `proposed`
- Scope: `Testing`
- Related: `BLUEPRINT.md §18.1, §14.4, §15.2, §11`

### 目的

テスト評価（1007 テスト、97% カバレッジ）とピアライブラリ比較（scikit-learn / LightGBM / Optuna / FLAML / PyCaret）により特定された構造的ギャップを補填し、新アルゴリズム追加・format_version 変更・パラメータ組み合わせ爆発に対する回帰耐性を確保する。

### 背景

現テストスイートは契約テスト・リーク防止・再現性・エラーパスで高品質だが、以下の5カテゴリに構造的な不足がある。

### Proposal: 5 カテゴリのテスト補強

#### カテゴリ A: 実 Artifact 互換テスト（優先度: 高）

**現状の問題**: 同一バージョン round-trip と `analysis_context.pkl` 欠損の擬似 legacy のみ。過去版が吐いた実 artifact をロードする fixture がない。Legacy 校正経路（`model.py` 325 行目 `oof_raw_scores is None` → probability 入力で calibrate する else 分岐）は実質デッドコード扱いで未検証。

**追加テスト**:

1. **Frozen artifact fixture**: CI で生成した artifact を `tests/fixtures/v1_regression/` / `tests/fixtures/v1_binary_calibrated/` に格納。`Model.load()` → `predict()` → 既知の期待値と比較。LightGBM / XGBoost が保存形式互換で実施している手法に準拠。
2. **Legacy calibration path**: `oof_raw_scores=None` の FitResult を手動構築し、`predict()` 時に probability 経由で calibrate が走ることを確認。model.py 321–326 行の else 分岐のカバレッジを保証。
3. **format_version rejection 明示テスト**: `format_version=99` の metadata.json → `DESERIALIZATION_FAILED` で reject。`format_version=0`（過去）も同様。
4. **Booster model string roundtrip**: `model_to_string()` → `model_from_string()` の往復が LightGBM バージョン間で壊れないことの検証（LightGBM #7186 の回帰検知）。
5. **metadata.json 部分欠損**: 必須フィールド（`feature_names`, `task`, `run_id` 等）を1つずつ削除し、各欠損で正しいエラーメッセージが出ることを検証。

#### カテゴリ B: Provider/Adapter 共通 Invariant チェック（優先度: 高）

**現状の問題**: adapter / e2e テストは LGBM 前提の手書き happy-path が中心。共有データも 2 列の dense float DataFrame に偏る。scikit-learn は `check_estimator` / `parametrize_with_checks` で API 共通条件を一括検証し、LightGBM も `all_x_types` / `all_y_types` と sklearn check を回している。LizyML は provider/adapter ごとの共通チェック層を持たない。

**追加テスト（`check_provider` スイート）**:

1. **Protocol メソッド存在・戻り値型チェック**:
   - `check_extract_model_params_returns_dict`: `extract_model_params()` が `dict[str, Any]` を返す。
   - `check_extract_smart_params_returns_dict`: `extract_smart_params()` が `dict[str, Any]` を返す。
   - `check_runtime_deps_nonempty`: `runtime_deps()` が空でない `dict[str, str]` を返す。
   - `check_default_space_nonempty`: `default_space(task)` が空でない `list[SearchDim]` を返す。

2. **Factory → fit → predict 往復チェック**:
   - `check_estimator_fit_predict_roundtrip`: 全タスク型（regression / binary / multiclass）× provider で fit → predict が完走し、出力 shape が正しい。
   - `check_estimator_predict_proba_shape`: binary → `(n, 2)`、multiclass → `(n, k)`。regression → `UNSUPPORTED_TASK`。
   - `check_pipeline_factory_returns_pipeline`: `build_pipeline_factory()()` が `BaseFeaturePipeline` を返す。

3. **Pickle 往復チェック**:
   - `check_estimator_pickle_roundtrip`: fit 済み adapter を pickle → unpickle し、predict 結果が一致。

4. **Importance チェック**:
   - `check_importance_after_fit`: fit 後に `importance("split")` と `importance("gain")` が feature_names と同じキーの dict を返す。

5. **データ多様性 fixture**:
   - `dense_float_2col`（既存）、`dense_float_20col`（高次元）、`mixed_dtype`（float + int + category）、`with_missing`（NaN 列）、`single_feature`（1列）、`high_cardinality_cat`（100+ unique category）。
   - 各 fixture を `check_estimator_fit_predict_roundtrip` にパラメタライズ。

#### カテゴリ C: Tuning 再現性・失敗マトリクス（優先度: 中）

**現状の問題**: 再現性テストは fit/predict/evaluate まで。tuning 側は callback と基本成功/失敗のみ。同一 seed で `best_params` / `best_score` / trial 順が固定されるか未検証。全 trial 失敗時の分岐（`tuner.py` 167 行目）は未到達。Optuna は seed 固定と逐次実行を再現性の前提として明示している。

**追加テスト**:

1. **tune() 再現性**: 同一 seed・同一データ・同一 space で 2 回 `tune()` を実行し、`best_params`, `best_score`, `len(trial_history)`, trial 順序が完全一致することを検証。
2. **全 trial 失敗**: objective が常に例外を送出する mock を注入し、`TUNING_FAILED` + `context["n_trials"]` を検証。`tuner.py` 167–175 行の `if not completed` 分岐をカバー。
3. **部分 trial 失敗**: 一部 trial のみ失敗させ、成功 trial の中から best が正しく選択されることを検証。
4. **NaN/inf 返却時**: objective が `float("nan")` や `float("inf")` を返した場合の挙動を検証（Optuna 側の pruned 処理との整合）。
5. **Search space と Config params の衝突**: Config に `learning_rate=0.1` を設定しつつ、search space にも `learning_rate` を含め、tune 結果が Config 値を上書きすることを検証。
6. **空の search space**: `space={}` でデフォルト space が使用されることの明示テスト。

#### カテゴリ D: 入力ソース・dtype・境界値の E2E（優先度: 中）

**現状の問題**: DataSource 単体では CSV/Parquet を読めるが、Model entry まで通すテストは CSV 中心。共通 helper も単純な float DataFrame。Parquet 経由の fit/predict/export/load、nullable dtype、重複列、空/1行入力、カテゴリ順序ずれなどは見当たらない。LightGBM/XGBoost は `all_x_types` / `all_y_types` でコンテナ型・dtype 差分を広く回している。

**追加テスト**:

1. **Parquet フルパイプライン**: Parquet ファイル → `data.path` → fit → export → load → predict の完走。CSV 経由のみだった E2E を拡張。
2. **float32 入力**: `float32` DataFrame を `fit()` に渡し、OOF / predict 結果が `float64` で返ることを確認。scikit-learn の `global_dtype` fixture に相当。
3. **nullable dtype**: `pd.array([1, 2, None], dtype="Int64")` を含む DataFrame → fit が正常に動作するか、明確なエラーを返すかを検証。
4. **空 DataFrame (0行)**: `fit()` → 明確なエラーメッセージ（`DATA_SCHEMA_INVALID` 等）。
5. **1行 DataFrame**: CV 不可能な最小ケース → 明確なエラーメッセージ。
6. **重複列名**: `pd.DataFrame({"a": ..., "a": ...})` → 明確なエラーメッセージ。
7. **極端な値**: `inf` / `-inf` / 非常に大きい値を含む DataFrame での fit 挙動。
8. **カテゴリ順序ずれ**: 学習時 `["a", "b", "c"]` → 推論時 `["c", "a", "b"]` の順序違い。列ズレテスト（test_column_drift.py）の拡張。

#### カテゴリ E: パラメータ組み合わせの Pairwise テスト（優先度: 中）

**現状の問題**: 各パラメータを1つずつ検証しているが、相互作用のテストがない。全組み合わせの直積は爆発するが、Pairwise（2因子間カバレッジ）なら ~20-30 ケースで主要な相互作用を検出できる。

**因子と値**:

| 因子 | 値 |
|------|-----|
| task | `regression`, `binary`, `multiclass` |
| split_method | `kfold`, `stratified_kfold`, `group_kfold`, `time_series` |
| calibration | `None`, `"platt"` |
| early_stopping | `True`, `False` |
| n_estimators | `5`, `100` |

**追加テスト**:

1. **Pairwise fit 完走テスト**: 上記因子の pairwise 組み合わせ（約 20-30 ケース）を `@pytest.mark.parametrize` で生成し、「有効な組み合わせは例外なく fit 完走する」「無効な組み合わせ（例: calibration + regression）は明確なエラーを返す」を検証。
2. **個別の重要な相互作用テスト**:
   - `calibration` + `group_kfold`: calibration splitter が group 制約を尊重するか。
   - `balanced=True` + `multiclass`: sample_weight が正しく計算されるか。
   - `feature_weights` + `auto_num_leaves`: smart params 同士の相互作用。
   - `tuning` + `calibration`: tune → fit(calibration) で best_params と calibration が両立するか。
   - `n_estimators=1` + `early_stopping`: 最小ラウンドでのエッジケース。
   - `features.exclude` + `features.categorical`: 除外列がカテゴリ列の場合。

### 影響範囲

- `tests/` 以下への追加のみ。`lizyml/` の実装コードは変更しない。
- `tests/fixtures/` にfrozen artifact を追加（CI 生成スクリプト含む）。
- `tests/_helpers.py` にデータ多様性 fixture を追加。

### 互換性

- テスト追加のみのため破壊的変更なし。
- frozen artifact fixture は `format_version=1` のスナップショットであり、将来の version bump 時に migration テストの基盤となる。

### 代替案

- 全組み合わせ直積テスト → 実行時間爆発（数千ケース）。pairwise で十分な因子間カバレッジを達成。
- Property-based テスト（Hypothesis）→ scikit-learn / LightGBM / Optuna / FLAML / PyCaret の5ライブラリすべて未採用。将来の検討項目とする。
- 可視化回帰テスト（画像 diff）→ Optuna のみ別リポで実施。現時点では low priority。

### 受け入れ基準

- カテゴリ A: frozen artifact fixture からの `Model.load()` → `predict()` が期待値と一致。legacy calibration path（`oof_raw_scores=None`）のカバレッジ到達。
- カテゴリ B: `check_provider` スイートが LGBMProvider に対して全チェック pass。新 provider 追加時に自動で全チェックが走る構造。
- カテゴリ C: 同一 seed の `tune()` が `best_params` / `best_score` 完全一致。全 trial 失敗時に `TUNING_FAILED` を返す。
- カテゴリ D: Parquet / float32 / nullable dtype の E2E が pass。0行/1行/重複列で明確なエラー。
- カテゴリ E: pairwise 組み合わせ全ケースで fit 完走 or 明確なエラー。
- 全体: 既存 1007 テストに影響なし。カバレッジ 97%+ 維持。

---

## H-0057: Split-derived OOF Coverage（TimeSeriesCV の OOF メトリクス NaN 解消）

- ID: `H-0057`
- Status: `implemented`
- Scope: `Evaluation | Metrics`
- Related: `BLUEPRINT.md §13.2, §7.1`

### 目的

TimeSeriesCV（expanding window）使用時に、最初の期間のサンプルがどの validation fold にも含まれないため OOF 予測値が NaN のまま残り、`evaluate()` / `evaluate_table()` の全体 OOF メトリクスが NaN になる問題を解消する。

### 背景

- `TimeSeriesSplit(n_splits=K)` では先頭 `n_samples // (K+1)` 行程度が全 fold で train 側にのみ含まれ、validation に一度も現れない。
- 現行の `Evaluator.evaluate()` は `oof_pred` 全行で metric を計算するため、NaN が混入しメトリクスも NaN になる。
- NaN マスク（`np.isnan` で除外）は「バグで予測されなかった行」と「仕様上カバーされない行」を区別できず、潜在バグを見落とすリスクがある。

### Proposal: Split-derived OOF Coverage Mask

1. **`compute_oof_valid_mask(splits_outer, n_samples)`** を `oof_assembly.py` に追加。
   - `SplitIndices.outer` の全 fold の `valid_idx` の和集合から boolean mask を生成。
   - NaN 検出ではなく、split 構造から決定論的に導出。

2. **`Evaluator.evaluate()` の OOF メトリクス計算を変更**。
   - mask の True 行のみで `oof` メトリクスを計算。
   - **カバー行に NaN がある場合は `ValueError`**（予測パイプラインのバグとして検知）。
   - `oof_per_fold` / IF メトリクスは変更なし。

3. **`metrics["raw"]["oof_coverage"]`** を追加（float, 0.0–1.0）。
   - KFold: 常に `1.0`。TimeSeriesCV: `< 1.0`。

4. **`evaluate_table()`** で `df.attrs["oof_coverage"]` として公開。

### 影響範囲

| 対象 | 変更内容 |
|------|---------|
| `oof_assembly.py` | `compute_oof_valid_mask()` 追加 |
| `evaluator.py` | mask ベースの OOF 計算 + `oof_coverage` 追加 |
| `table_formatter.py` | `df.attrs` に `oof_coverage` |
| `_model_metrics.py` | calibrated パスは `splits` 保持済み → 変更不要 |
| FitResult | **変更なし**（mask は SplitIndices から導出） |

### 互換性

- **KFold（既存の主要ユースケース）**: 全行カバーのため挙動は完全に同一。
- **TimeSeriesCV**: `metrics["raw"]["oof"]` が NaN → 有効な数値に変わる（改善のみ）。
- **`metrics["raw"]` への `oof_coverage` キー追加**: 既存コードは未知キーを参照しない限り影響なし。`filter_metrics()` は非 dict 値をパススルーするため互換。

### 代替案（却下）

- **NaN マスク**: `np.isnan(oof_pred)` で除外。→ バグ由来の NaN も黙殺されるため却下。

### 受け入れ基準

- `compute_oof_valid_mask` が split indices から正しい bool mask を返す（unit test）。
- カバー行に NaN → `ValueError`（バグ検知テスト）。
- 非カバー行の NaN は正常スキップ。
- KFold で `oof_coverage == 1.0`、TimeSeriesCV で `oof_coverage < 1.0`。
- TimeSeriesCV の OOF メトリクスが finite（NaN でない）。
- `evaluate_table().attrs["oof_coverage"]` が float。
- 既存テスト全通し（後方互換）。

### Decision

- Date: 2026-03-17
- Result: Accepted — split 構造からの決定論的マスク + バグ検知 assertion の方針で実装する。

### 備考

- `metrics["calibrated"]` には `oof_coverage` を含めない（現状維持）。calibrated の cross-fit 分割は `calibration.n_splits` で outer とは独立しており、coverage が異なりうるため、raw の値を流用すると不正確になる。
- この不整合は H-0058（Outer Split を Calibration で再利用する提案）で構造的に解消される予定。H-0058 が実装されれば calibrated の coverage は raw と一致するため、別途 coverage を公開する必要がなくなる。

---

## H-0058: Outer Split を Calibration Cross-fit で再利用

- ID: `H-0058`
- Status: `implemented`
- Scope: `Calibration | Split | Config`
- Related: `BLUEPRINT.md §10.5, §12, §13.2`, `H-0057`

### 目的

calibration cross-fit が outer CV とは独立した分割を使うことで生じる coverage 不整合・コード複雑性・概念的な非対称を構造的に解消する。

### 背景

現状（H-0057 後）では calibration cross-fit は `calibration.n_splits` で独立した分割を生成する。outer CV と同じ `split.method` を継承するが fold 数だけが独立しており、TimeSeriesCV 使用時に raw OOF と calibrated OOF のカバレッジが乖離する。

```
outer CV:       TimeSeriesSplit(n_splits=5) → coverage ≈ 83%
calibration CV: TimeSeriesSplit(n_splits=3) → coverage ≈ 75%
```

H-0057 では `_model_metrics.py` で splits を差し替える workaround を追加して対処したが、本質的には分割構造が二重になっていることが根本原因。

### リーク安全性

calibration cross-fit は `(oof_scores, y)` のみを入力とし、X は使わない（§12.1）。

3-fold の具体例（データ = A, B, C）:

| step | fold | 学習データ | 予測対象 |
|------|------|-----------|---------|
| Outer CV | 0 | X[B+C], y[B+C] → model_0 | oof[A] |
| Outer CV | 1 | X[A+C], y[A+C] → model_1 | oof[B] |
| Outer CV | 2 | X[A+B], y[A+B] → model_2 | oof[C] |
| Cal cross-fit | 0 | oof[B+C], y[B+C] → cal_0 | cal_oof[A] |
| Cal cross-fit | 1 | oof[A+C], y[A+C] → cal_1 | cal_oof[B] |
| Cal cross-fit | 2 | oof[A+B], y[A+B] → cal_2 | cal_oof[C] |

行 A に注目: `oof[A]` は A を見ていない model_0 が生成、`cal_oof[A]` は oof[A] を見ていない cal_0 が生成。同一行リーク経路なし。

C_final は `fit(oof[全行], y[全行])` で学習し推論専用。評価には使わない。

### Proposal

calibration cross-fit で `fit_result.splits.outer` をそのまま再利用する。

```python
# 現在（model.py）
cal_splitter = build_calibration_splitter(cfg)
cal_split_indices = list(cal_splitter.split(...))

# 変更後
cal_split_indices = fit_result.splits.outer
```

### 影響範囲

| 対象 | 変更内容 |
|------|---------|
| `CalibrationConfig.n_splits` | deprecated（残すが無視、UserWarning 出力） |
| `model.py _run_calibration()` | `build_calibration_splitter` → `fit_result.splits.outer` に置換 |
| `_model_factories.py` | `build_calibration_splitter` を deprecated 化 |
| `_model_metrics.py` | splits 差替えロジック削除（H-0057 workaround が不要に） |
| `SplitIndices.calibration` | outer と同一値（冗長だが互換性のため残す） |
| `cross_fit_calibrate()` | 変更なし（split_indices を受け取るだけ） |
| `BLUEPRINT.md §10.5` | calibration CV 規約を改訂 |
| `BLUEPRINT.md §13.2` | calibrated coverage が raw と一致する旨を追記 |

### 互換性

#### Config 互換性
- `calibration.n_splits` を指定した場合は `UserWarning` を出力し無視。
- `extra="forbid"` なのでフィールド自体は残す（削除すると既存 Config が壊れる）。
- 将来の `config_version` 更新時に削除を検討。

#### 保存互換性
- `SplitIndices.calibration` に outer と同一のリストを保存 → 既存の `Model.load()` は問題なし。
- `format_version` の変更は不要（データ構造は同一、値が変わるだけ）。

### 代替案（却下）

1. **calibrated に oof_coverage を追加（H-0057 案 B）**: H-0058 が来ると冗長フィールドになる。
2. **calibration.n_splits のデフォルトを outer に合わせる**: 結局独立分割が残り、method パラメータ（gap/embargo）の二重管理が消えない。
3. **現状維持**: `_model_metrics.py` の splits 差替え workaround を永続させることになる。

### 受け入れ基準

- `calibration.n_splits` 指定時に `UserWarning` が出力される。
- calibration cross-fit が `fit_result.splits.outer` を使用する。
- `SplitIndices.calibration` が outer と同一値。
- `_model_metrics.py` の splits 差替えロジックが削除される。
- TimeSeriesCV で calibrated OOF の実質 coverage が `metrics["raw"]["oof_coverage"]` と一致する。
- 既存の `Model.load()` で旧 artifact が問題なくロードできる。
- リーク検知テスト（`test_calibration_leakage`）が引き続き pass。

### Decision

- Date: 2026-03-17
- Result: Accepted — outer CV splits を calibration cross-fit でそのまま再利用する方針で実装する。

---

## H-0059: Codegen Export — LizyML 非依存の学習・推論コード生成

- ID: `H-0059`
- Status: `accepted`
- Decision: `v0.3.0 でリリース (2026-03-20)`
- Scope: `Export | Public API`
- Related: `BLUEPRINT.md §6.6, §15.4`, `skills/export/SKILL.md`

### 目的

LizyML で構築したモデルを本番環境に載せる際、ライブラリ全体を依存に含めるとデバッグが困難になる。`Model.export_code()` で **LizyML 非依存の学習・推論コード** を自動生成し、以下を実現する:

1. **再学習パイプライン**: 新データ到着時に同一設定で refit + calibrator 再構築ができる
2. **学習コードの透明性**: fit 時に何が起きているかを人間が読めるコードで確認・検証できる
3. **最小依存での本番推論**: LizyML なしで推論を実行できる

### 背景

現在の `Model.export()` は LizyML Artifact（joblib pickle）を出力し、`Model.load().predict()` で推論する。本番環境において:

1. **デバッグ困難**: エラー発生時に LizyML 内部を追う必要がある
2. **依存の重さ**: LizyML + pydantic + 全 optional deps が本番に必要
3. **再学習の不透明性**: `Model.fit()` の内部で何が起きているか追えない

### Proposal

#### 出力構造（3 ファイル + artifacts ディレクトリ）

```
{path}/
├── config.json             # 全設定の単一ソース（パラメータ変更はここだけ）
├── train.py                # 学習: pipeline fit → LightGBM refit → calibration
├── predict.py              # 推論: pipeline transform → predict → calibrate
├── requirements.txt        # 最小依存
├── test_equivalence.py     # LizyML との一致検証
└── artifacts/              # train.py が生成・更新
    ├── model.txt           # LightGBM Booster テキスト形式
    ├── pipeline_state.json # 学習済み feature pipeline 状態
    ├── calibrator.json     # Calibrator パラメータ（binary のみ）
    └── calibrator_model.txt # Isotonic Booster（該当時のみ）
```

#### config.json

ユーザーが確認・編集する唯一のファイル。`_` prefix はメタ情報（読み取り専用）。

```json
{
  "_generated_by": "lizyml 0.2.0",
  "_run_id": "7e77ba4b-...",
  "_task": "binary",
  "_target_col": "y",
  "_timestamp": "2026-03-19T12:00:00",

  "feature_names": ["age", "income", "category_a", "category_b"],
  "categorical_features": ["category_a", "category_b"],

  "lgbm_params": {
    "objective": "binary",
    "metric": "binary_logloss",
    "num_leaves": 31,
    "learning_rate": 0.05,
    "feature_fraction": 0.8,
    "verbosity": -1
  },
  "num_boost_round": 1000,
  "early_stopping_rounds": 50,
  "validation_ratio": 0.2,
  "seed": 42,

  "calibration_method": "platt",
  "calibration_n_splits": 5
}
```

#### train.py

```python
#!/usr/bin/env python3
"""Train a LightGBM model and fit a probability calibrator.

Usage:
    python train.py train_data.csv
    python train.py train_data.parquet --no-calibration

Steps:
    1. Fit feature pipeline (learn category mappings → pipeline_state.json)
    2. Train LightGBM on full data (→ model.txt)
    3. Generate OOF scores via CV for calibration
    4. Fit calibrator on OOF scores (→ calibrator.json)

Generated by lizyml — https://github.com/nbx-liz/LizyML
"""
from __future__ import annotations

import argparse
import json
import logging
import math
import sys
from pathlib import Path

import lightgbm as lgb
import numpy as np
import pandas as pd
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import KFold, StratifiedKFold

logging.basicConfig(
    level=logging.INFO, format="%(asctime)s  %(message)s", datefmt="%H:%M:%S",
)
log = logging.getLogger(__name__)

ROOT = Path(__file__).parent
ARTIFACTS = ROOT / "artifacts"

with open(ROOT / "config.json") as _f:
    CFG = json.load(_f)


# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#  Feature Pipeline
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

def fit_pipeline(df: pd.DataFrame) -> dict:
    """Learn category mappings and save pipeline state."""
    expected = CFG["feature_names"]
    missing = sorted(set(expected) - set(df.columns))
    if missing:
        raise ValueError(f"Missing columns: {missing}")

    mappings: dict[str, dict[str, int]] = {}
    for col in CFG["categorical_features"]:
        cats = sorted(str(v) for v in df[col].dropna().unique())
        mappings[col] = {v: i for i, v in enumerate(cats)}
        log.info("    %s: %d categories", col, len(cats))

    state = {
        "feature_names": expected,
        "categorical_features": CFG["categorical_features"],
        "category_mappings": mappings,
    }
    ARTIFACTS.mkdir(parents=True, exist_ok=True)
    with open(ARTIFACTS / "pipeline_state.json", "w") as f:
        json.dump(state, f, indent=2, ensure_ascii=False)
    return state


def transform(df: pd.DataFrame, state: dict) -> pd.DataFrame:
    """Apply fitted pipeline to a DataFrame."""
    X = df[state["feature_names"]].copy()
    for col, mapping in state.get("category_mappings", {}).items():
        if col in X.columns:
            X[col] = X[col].astype(str).map(mapping)  # unseen → NaN
    return X


# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#  LightGBM Training
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

def train_lgbm(X: pd.DataFrame, y: pd.Series, cat_cols: list[str]) -> lgb.Booster:
    """Train LightGBM with optional early stopping via holdout."""
    ds_full = lgb.Dataset(X, label=y, categorical_feature=cat_cols or "auto")
    callbacks: list = [lgb.log_evaluation(period=200)]
    train_set = ds_full
    valid_sets, valid_names = [ds_full], ["train"]

    ratio = CFG.get("validation_ratio", 0)
    es_rounds = CFG.get("early_stopping_rounds")
    if ratio > 0 and es_rounds:
        n = len(y)
        rng = np.random.default_rng(CFG["seed"])
        idx = rng.permutation(n)
        n_val = max(1, int(n * ratio))

        train_set = lgb.Dataset(
            X.iloc[idx[n_val:]], label=y.iloc[idx[n_val:]],
            categorical_feature=cat_cols or "auto",
        )
        valid_ds = lgb.Dataset(
            X.iloc[idx[:n_val]], label=y.iloc[idx[:n_val]],
            reference=train_set,
        )
        valid_sets = [train_set, valid_ds]
        valid_names = ["train", "valid"]
        callbacks.insert(0, lgb.early_stopping(es_rounds, verbose=True))
        log.info("    holdout: %d train / %d valid", n - n_val, n_val)

    booster = lgb.train(
        CFG["lgbm_params"], train_set,
        num_boost_round=CFG["num_boost_round"],
        valid_sets=valid_sets, valid_names=valid_names,
        callbacks=callbacks,
    )
    booster.save_model(str(ARTIFACTS / "model.txt"))
    log.info("    saved model.txt (best_iteration=%d)", booster.best_iteration)
    return booster


# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#  Calibration
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

def _sigmoid(x: np.ndarray) -> np.ndarray:
    return np.where(x >= 0, 1 / (1 + np.exp(-x)), np.exp(x) / (1 + np.exp(x)))


def _generate_oof(X: np.ndarray, y: np.ndarray) -> np.ndarray:
    """Lightweight CV to produce OOF raw scores (logits)."""
    n_splits = CFG.get("calibration_n_splits", 5)
    task = CFG["_task"]
    seed = CFG["seed"]

    kf = (StratifiedKFold if task == "binary" else KFold)(
        n_splits=n_splits, shuffle=True, random_state=seed,
    )
    oof = np.full(len(y), np.nan)
    for i, (trn, val) in enumerate(kf.split(X, y)):
        log.info("    CV fold %d/%d", i + 1, n_splits)
        ds = lgb.Dataset(X[trn], label=y[trn])
        bst = lgb.train(CFG["lgbm_params"], ds,
                        num_boost_round=CFG["num_boost_round"],
                        callbacks=[lgb.log_evaluation(0)])
        oof[val] = bst.predict(X[val], raw_score=True)
    return oof


def _fit_platt(scores: np.ndarray, y: np.ndarray) -> dict:
    lr = LogisticRegression(C=1.0, solver="lbfgs", max_iter=200)
    lr.fit(scores.reshape(-1, 1), y)
    return {"method": "platt",
            "a": float(lr.coef_[0, 0]), "b": float(lr.intercept_[0])}


def _fit_beta(scores: np.ndarray, y: np.ndarray) -> dict:
    from scipy.optimize import minimize
    s = np.clip(_sigmoid(scores), 1e-10, 1 - 1e-10)
    yf = y.astype(float)
    ls, l1s = np.log(s), np.log(1 - s)

    def nll(p):
        prob = np.clip(_sigmoid(p[0]*ls + p[1]*l1s + p[2]), 1e-10, 1-1e-10)
        return float(-np.sum(yf*np.log(prob) + (1-yf)*np.log(1-prob)))

    r = minimize(nll, x0=[1, 1, 0], method="L-BFGS-B")
    return {"method": "beta",
            "a": float(r.x[0]), "b": float(r.x[1]), "c": float(r.x[2])}


def _fit_isotonic(scores: np.ndarray, y: np.ndarray) -> dict:
    n = len(scores)
    params = {
        "objective": "binary", "metric": "binary_logloss",
        "monotone_constraints": [1], "monotone_constraints_method": "advanced",
        "num_leaves": 7, "max_depth": 3, "learning_rate": 0.03,
        "lambda_l2": 5.0, "min_data_in_leaf": max(1, math.ceil(n * 0.01)),
        "verbose": -1, "seed": CFG["seed"],
    }
    rng = np.random.default_rng(CFG["seed"])
    n_val = max(1, int(n * 0.1))
    idx = rng.permutation(n)
    X_cal = scores.reshape(-1, 1)

    ds_t = lgb.Dataset(X_cal[idx[n_val:]], label=y[idx[n_val:]].astype(float))
    ds_v = lgb.Dataset(X_cal[idx[:n_val]], label=y[idx[:n_val]].astype(float),
                       reference=ds_t)
    bst = lgb.train(params, ds_t, num_boost_round=1000,
                    valid_sets=[ds_v], valid_names=["valid"],
                    callbacks=[lgb.early_stopping(100, verbose=False),
                               lgb.log_evaluation(0)])
    bst.save_model(str(ARTIFACTS / "calibrator_model.txt"))
    return {"method": "isotonic", "model_file": "calibrator_model.txt"}


_CAL_FITTERS = {"platt": _fit_platt, "beta": _fit_beta, "isotonic": _fit_isotonic}


def fit_calibrator(X: np.ndarray, y: np.ndarray) -> dict | None:
    """Generate OOF scores and fit calibrator. Returns params or None."""
    method = CFG.get("calibration_method")
    if not method or CFG["_task"] != "binary":
        return None

    log.info("[3/4] Generating OOF scores ...")
    oof = _generate_oof(X, y)

    log.info("[4/4] Fitting %s calibrator ...", method)
    params = _CAL_FITTERS[method](oof, y)
    with open(ARTIFACTS / "calibrator.json", "w") as f:
        json.dump(params, f, indent=2)
    log.info("    saved calibrator.json")
    return params


# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#  Main
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

def train(df: pd.DataFrame, *, calibrate: bool = True) -> None:
    target = CFG["_target_col"]
    y = df[target]
    X_raw = df.drop(columns=[target])

    log.info("[1/4] Fitting feature pipeline ...")
    state = fit_pipeline(X_raw)
    X = transform(X_raw, state)

    log.info("[2/4] Training LightGBM ...")
    cat_cols = [c for c in CFG["categorical_features"] if c in X.columns]
    train_lgbm(X, y, cat_cols)

    if calibrate:
        fit_calibrator(X.values, y.values)
    else:
        log.info("[3/4] Calibration skipped")
        log.info("[4/4] —")

    log.info("Done.")


def main() -> None:
    p = argparse.ArgumentParser(description="Train LightGBM (LizyML codegen)")
    p.add_argument("data", help="CSV or Parquet file")
    p.add_argument("--no-calibration", action="store_true")
    args = p.parse_args()

    path = Path(args.data)
    df = pd.read_parquet(path) if path.suffix == ".parquet" else pd.read_csv(path)

    target = CFG["_target_col"]
    if target not in df.columns:
        log.error('Target "%s" not found. Columns: %s', target, list(df.columns))
        sys.exit(1)

    train(df, calibrate=not args.no_calibration)


if __name__ == "__main__":
    main()
```

#### predict.py

```python
#!/usr/bin/env python3
"""Run inference with a trained model.

Usage:
    python predict.py test_data.csv
    python predict.py test_data.csv -o predictions.csv

Generated by lizyml — https://github.com/nbx-liz/LizyML
"""
from __future__ import annotations

import argparse
import json
import logging
from pathlib import Path

import lightgbm as lgb
import numpy as np
import pandas as pd

logging.basicConfig(
    level=logging.INFO, format="%(asctime)s  %(message)s", datefmt="%H:%M:%S",
)
log = logging.getLogger(__name__)

ROOT = Path(__file__).parent
ARTIFACTS = ROOT / "artifacts"

with open(ROOT / "config.json") as _f:
    CFG = json.load(_f)


# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#  Feature Transform (predict-time: no re-fitting)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

def _load_pipeline() -> dict:
    path = ARTIFACTS / "pipeline_state.json"
    if not path.exists():
        raise FileNotFoundError(f"{path} not found. Run train.py first.")
    with open(path) as f:
        return json.load(f)


def transform(df: pd.DataFrame) -> pd.DataFrame:
    """Select expected columns and apply categorical encoding."""
    state = _load_pipeline()
    expected = state["feature_names"]

    missing = sorted(set(expected) - set(df.columns))
    if missing:
        raise ValueError(
            f"Missing {len(missing)} column(s): {missing}. "
            f"Expected: {expected}"
        )

    extra = sorted(set(df.columns) - set(expected))
    if extra:
        log.warning("Ignoring %d extra column(s): %s", len(extra), extra)

    X = df[expected].copy()
    for col, mapping in state.get("category_mappings", {}).items():
        if col in X.columns:
            X[col] = X[col].astype(str).map(mapping)  # unseen → NaN
    return X


# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#  Calibration (apply only)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

def _sigmoid(x: np.ndarray) -> np.ndarray:
    return np.where(x >= 0, 1 / (1 + np.exp(-x)), np.exp(x) / (1 + np.exp(x)))


def _load_calibrator() -> dict | None:
    path = ARTIFACTS / "calibrator.json"
    return json.load(open(path)) if path.exists() else None


def calibrate(raw_scores: np.ndarray, cal: dict) -> np.ndarray:
    """Map raw logits → calibrated probabilities."""
    m = cal["method"]
    if m == "platt":
        return 1 / (1 + np.exp(-(cal["a"] * raw_scores + cal["b"])))
    if m == "beta":
        s = np.clip(_sigmoid(raw_scores), 1e-10, 1 - 1e-10)
        logit = cal["a"] * np.log(s) + cal["b"] * np.log(1 - s) + cal["c"]
        return np.clip(_sigmoid(logit), 0, 1)
    if m == "isotonic":
        bst = lgb.Booster(model_file=str(ARTIFACTS / cal["model_file"]))
        return np.clip(bst.predict(raw_scores.reshape(-1, 1)), 0, 1)
    raise ValueError(f'Unknown calibration: "{m}"')


# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#  Predict
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

def predict(df: pd.DataFrame) -> dict[str, np.ndarray | None]:
    """Run inference. Returns {"pred": ..., "proba": ...}."""
    X = transform(df)
    booster = lgb.Booster(model_file=str(ARTIFACTS / "model.txt"))
    task = CFG["_task"]

    if task == "regression":
        return {"pred": np.asarray(booster.predict(X), dtype=np.float64),
                "proba": None}

    if task == "binary":
        proba = np.asarray(booster.predict(X), dtype=np.float64)
        cal = _load_calibrator()
        if cal:
            logits = np.asarray(booster.predict(X, raw_score=True), dtype=np.float64)
            proba = calibrate(logits, cal)
        return {"pred": (proba > 0.5).astype(np.int64), "proba": proba}

    if task == "multiclass":
        proba = np.asarray(booster.predict(X), dtype=np.float64)
        return {"pred": np.argmax(proba, axis=1).astype(np.int64), "proba": proba}

    raise ValueError(f'Unknown task: "{task}"')


# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

def main() -> None:
    p = argparse.ArgumentParser(description="Predict (LizyML codegen)")
    p.add_argument("data", help="CSV or Parquet file")
    p.add_argument("-o", "--output", default="predictions.csv")
    args = p.parse_args()

    path = Path(args.data)
    df = pd.read_parquet(path) if path.suffix == ".parquet" else pd.read_csv(path)
    result = predict(df)

    out = pd.DataFrame({"pred": result["pred"]})
    if result["proba"] is not None:
        if result["proba"].ndim == 1:
            out["proba"] = result["proba"]
        else:
            for i in range(result["proba"].shape[1]):
                out[f"proba_{i}"] = result["proba"][:, i]
    out.to_csv(args.output, index=False)
    log.info("Saved %d rows → %s", len(out), args.output)


if __name__ == "__main__":
    main()
```

### 設計判断

| 判断 | 理由 |
|------|------|
| **2 ファイル構成** | train.py (~200行) と predict.py (~120行) で全体 ~320行。1ファイルでは長すぎ、4ファイルでは分散しすぎ |
| **config.json に設定集約** | コードを触らずにパラメータ変更可能。`_` prefix でメタ情報を分離 |
| **train.py に pipeline/calibration を内包** | 学習時しか使わないロジック（fit_pipeline, OOF生成, calibrator fit）を1箇所に集約 |
| **predict.py に transform/calibrate を内包** | 推論に必要なロジックだけ。本番デプロイ時は predict.py + config.json + artifacts/ のみ |
| **Booster テキスト形式** | pickle 不要、人間可読、バージョン間互換性高 |
| **Calibrator: method 別に適切な保存** | Platt/Beta → JSON 数値パラメータ、Isotonic → Booster テキスト |
| **OOF 生成に StratifiedKFold** | binary でクラス比保持（LizyML デフォルトと一致） |
| **refit + calibrator 再構築** | predict-only だと calibrator が陳腐化するリスクを回避 |
| **SHAP 非対応（初期版）** | 依存が重い。将来拡張 |

### 影響範囲

| 対象 | 変更内容 |
|------|---------|
| `Model` (公開 API) | `export_code(path)` メソッド追加 |
| `lizyml/codegen/` (新規) | generator.py: FitResult + config → ファイル生成 |
| `BaseCalibratorAdapter` | `export_params() -> dict` 追加 |
| `PlattCalibrator` | `export_params()`: coef_, intercept_ → a, b |
| `BetaCalibrator` | `export_params()`: _params → a, b, c |
| `IsotonicCalibrator` | `export_params()` + `save_model_text(path)` |
| `LGBMAdapter` | `save_model_text(path)` 追加 |
| `NativeFeaturePipeline` | `export_state_json(path)` 追加 |
| `BLUEPRINT.md §6.5, §15.3` | codegen export 仕様追記 |

### 互換性

- 既存 API (`export()` / `load()`) は変更なし。`export_code()` は新規追加。
- `format_version` 変更不要。codegen は既存 Artifact 形式とは独立。
- 将来の非 LGBM 対応時は Provider に `export_model_text()` を追加。

### 代替案（却下）

1. **Predict のみ codegen**: 再学習時に calibrator が陳腐化するリスク。LizyML が本番依存から外れない。
2. **4 ファイル分割**: pipeline.py / calibration.py を分離すると可読性向上だがファイル数が増えて見通しが悪化。
3. **1 ファイル構成**: 400行超で可読性低下。学習・推論の責務が混在。

### 制約・前提

- 初期実装は **LightGBM のみ**。
- CV は calibrator の OOF 生成のみ（評価メトリクスは含まない）。
- multiclass の calibration は対象外（binary のみ）。
- `train.py` 実行時に `scikit-learn` が必要（KFold / StratifiedKFold / LogisticRegression）。
- `predict.py` 実行時は `lightgbm` + `numpy` + `pandas` のみ（calibration 有無に関わらず）。

### 受け入れ基準

**学習 (train.py):**
- `python train.py data.csv` で全 artifacts が生成される。
- 生成コードに `import lizyml` が存在しない。
- 同一データ・同一 seed で refit モデルの予測値が `rtol=1e-7` で一致。
- `--no-calibration` で calibrator 学習がスキップされる。

**推論 (predict.py):**
- `python predict.py test.csv` で予測結果が出力される。
- `Model.predict()` と codegen 出力が `rtol=1e-7` で一致。
- regression / binary / multiclass の 3 タスクで pass。
- binary + Platt / Isotonic / Beta で calibrated probability が一致。
- 列ズレ検知が動作（missing → ValueError、extra → warning）。

**Calibrator 再学習:**
- 新データで `python train.py new_data.csv` → calibrator が再構築される。

**共通:**
- 依存が requirements.txt に収まる。
- E2E テスト: fit → export_code → train → predict → 結果検証。

---

## H-0060: blocked_group_kfold — 2軸交差検証（期間 × グループ）

- ID: `H-0060`
- Status: `accepted`
- Scope: `Split | InnerValid | Config | Public API`
- Related: `BLUEPRINT §5.4, §10`

### 目的

期間軸（blocks）とグループ軸（groups）の直積で交差検証を行う新しい split method `blocked_group_kfold` を追加する。時間的な前方検証とグループ間リーク防止を同時に実現する。

### 背景

既存の splitter は1軸の分割のみ対応する（時間 or グループ）。実務では以下のような2軸分割が必要になる。

- 金融: ユーザーID × 月次データ（2月以前で学習、3月以降で評価、学習/評価でユーザーが異なる）
- 医療: 患者ID × 受診期間
- 小売: 店舗ID × 週次/月次データ

既存の `group_time_series` はグループの出現順で時系列分割するが、以下を満たさない。

1. 任意の境界値（cutoff）で期間を区切れない
2. グループ軸で KFold できない（データ利用効率が低い）
3. expanding / sliding の窓制御ができない

### Proposal

#### Config 構造

```yaml
split:
  method: blocked_group_kfold
  blocks:                             # ── 期間軸（何で区切るか）
    col: date                         #   区切りに使うカラム
    cutoffs: ["2025-02", "2025-03"]   #   境界値リスト（valid 期間の開始点）
    mode: sliding                     #   expanding | sliding
    train_window: 2                   #   sliding 時: train に使う期間数
  groups:                             # ── グループ軸（何で分けるか）
    col: user_id                      #   グループ分割するカラム
    n_splits: 3                       #   グループの分割数
    stratify: auto                    #   auto | true | false
    shuffle: true                     #   グループ分割時のシャッフル
  min_train_rows: 10                  #   fold スキップ閾値（train）
  min_valid_rows: 5                   #   fold スキップ閾値（valid）
```

#### blocks セクション

| フィールド | 型 | デフォルト | 説明 |
|---|---|---|---|
| `col` | `str` | **必須** | 期間を定義するカラム名。ソート可能な型 |
| `cutoffs` | `list` | **必須** | 境界値リスト。各値が valid 期間の開始点 |
| `mode` | `"expanding" \| "sliding"` | `"expanding"` | train 期間の構成方式 |
| `train_window` | `int \| null` | `null` | `sliding` 時のみ有効。train に使う期間数 |

#### groups セクション

| フィールド | 型 | デフォルト | 説明 |
|---|---|---|---|
| `col` | `str` | **必須** | グループ分割するカラム名 |
| `n_splits` | `int` | **必須** | グループの分割数（K） |
| `stratify` | `"auto" \| true \| false` | `"auto"` | ターゲット分布による層化 |
| `shuffle` | `bool` | `true` | グループ分割時のシャッフル |

`stratify: auto` は binary/multiclass → 層化あり、regression → 層化なし。層化時はグループごとの代表ラベル（多数決クラス）で層化分割する。

#### 期間の定義

`cutoffs: [C₁, C₂, ..., Cₙ]` から `n+1` 個の期間を生成:

- P₀: `col < C₁`
- P₁: `C₁ ≤ col < C₂`
- Pₙ: `col ≥ Cₙ`

**expanding**: fold k の train = P₀ + ... + Pₖ、valid = Pₖ₊₁
**sliding** (`train_window=W`): fold k の train = 直前 W 期間、valid = Pₖ₊₁

時間 fold 数 = `len(cutoffs)`

#### Fold 生成アルゴリズム

```
for each 時間fold t:
    train_period_rows = 期間割り当てで train に属する全行
    valid_period_rows = 期間割り当てで valid に属する全行
    all_users = unique(groups[train_period_rows ∪ valid_period_rows])
    user_folds = StratifiedGroupKFold(all_users, n_splits=K)

    for each ユーザーfold u:
        train_users, valid_users = user_folds[u]
        train_idx = train_period_rows ∩ rows_of(train_users)
        valid_idx = valid_period_rows ∩ rows_of(valid_users)
        # 除外: train期間×valid_users, valid期間×train_users
        if len(train_idx) >= min_train_rows and len(valid_idx) >= min_valid_rows:
            yield (train_idx, valid_idx)
        else:
            warn and skip
```

合計 fold 数 = `len(cutoffs) × groups.n_splits − skip数`

#### Inner Valid（early stopping）

新規 strategy `BlockedGroupInnerValid` を追加する。

**自動解決ルール:**

| タスク | 戦略 | 動作 |
|---|---|---|
| binary / multiclass | `BlockedGroupInnerValid` | グループ分離 + 各クラス末尾グループ + 層化 |
| regression | `BlockedGroupInnerValid` | グループ分離 + 末尾グループ |
| フォールバック | `StratifiedTimeHoldoutInnerValid` | グループ数 < 4 で自動切替 |

**BlockedGroupInnerValid アルゴリズム:**

1. outer fold train 内のユニークグループを取得
2. 各グループの代表ラベルを算出（多数決クラス）※分類時のみ
3. 各グループの最終出現時刻でソート
4. 分類時: 各クラス内で末尾 `ratio` 分のグループを inner valid に割り当て（各クラス最低1グループ保証）
5. 回帰時: 末尾 `ratio` 分のグループを inner valid に割り当て
6. グループ単位で完全分離（同一グループが inner train/valid に跨がらない）

**フォールバック条件:** `n_unique_groups < 4` の場合、`StratifiedTimeHoldoutInnerValid`（各クラスの末尾行から `ratio` 分を取得）にフォールバックし、警告を出す。

**StratifiedTimeHoldoutInnerValid:** 各クラス内で時間順序を保持し、末尾 `ratio` 分を inner valid に取る。全クラスが inner valid に最低1行含まれることを保証しつつ、クラス内では時間順序を維持する。

#### バリデーション

- `blocks.col` と `groups.col` が同一カラムの場合 → `CONFIG_INVALID`
- `mode: "sliding"` で `train_window` 未指定 → `CONFIG_INVALID`
- `mode: "expanding"` で `train_window` 指定 → 警告（値は無視）
- `cutoffs` が空 → `CONFIG_INVALID`
- `blocks.col` の値が比較不能 → `DATA_SCHEMA_INVALID`

### 設計判断

| 判断項目 | 選択 | 理由 |
|---|---|---|
| Purge vs Group KFold | Group KFold | データ利用効率が高い。Purge は除外行が多すぎる |
| Config 構造 | セクション分離（blocks/groups） | 2つの軸の役割が視覚的に明確 |
| BaseSplitter IF 変更 | 変更なし | blocks.col 値は Facade がコンストラクタに注入 |
| Inner Valid | 専用 strategy（BlockedGroupInnerValid） | outer と同じグループ分離を inner でも適用 |
| 層化 | auto（タスク依存） | 既存慣例（StratifiedKFold デフォルト）と整合 |
| フォールバック | グループ数 < 4 で行レベル分割 | 少数グループ時の安定性確保 |

### 影響範囲

| 対象 | 変更内容 |
|---|---|
| **新規**: `lizyml/splitters/blocked_group_kfold.py` | `BlockedGroupKFoldSplitter` |
| **新規**: `lizyml/training/inner_valid.py` に追加 | `BlockedGroupInnerValid`, `StratifiedTimeHoldoutInnerValid` |
| `lizyml/config/schema.py` | `BlockedGroupKFoldConfig`, `SplitConfig` union 更新 |
| `lizyml/core/_model_factories.py` | factory 分岐, inner valid auto 解決 |
| `lizyml/core/model.py` | `blocks.col` 抽出 + splitter コンストラクタ注入 |
| `lizyml/splitters/__init__.py` | re-export |
| `BLUEPRINT.md` | §5.4, §10.2, §10.3 更新 |

### 互換性

- 既存 Config / splitter に変更なし（新規 method の追加のみ）
- BaseSplitter インターフェース変更なし
- 既存テストへの影響なし

### 代替案（却下）

1. **Purge 方式**: train/valid に跨がるグループを除去する。データ利用効率が低い（各 fold で 30-50% が除外される）。
2. **専用 splitter 量産**: Group+Time、Group+Group 等の組み合わせごとに専用クラスを作る。組み合わせ爆発。
3. **BaseSplitter IF 拡張**: `split()` に `time_order` パラメータを追加。既存全 splitter のシグネチャ変更が必要。

### 制約・前提

- `blocks.col` は順序付き型（比較演算可能）が必要
- Facade が `blocks.col` でデータをソートする（既存 TS method と同じ規約）
- 2軸分離の構造上、各 fold で「train期間 × valid_users」と「valid期間 × train_users」の行は除外される

### 受け入れ基準

**契約:**
- fold 数 = `len(cutoffs) × groups.n_splits − skip数`
- 各 fold で `train_users ∩ valid_users == ∅`
- 各 fold で train 行の `blocks.col` 値が train 期間内、valid 行が valid 期間内

**再現性:**
- 同一 seed → 同一 fold indices

**層化:**
- binary/multiclass で各 user fold のクラス分布が均等（±許容範囲）

**Inner Valid:**
- inner train/valid でグループ完全分離
- inner valid グループが時間的に遅いグループから選択される
- 分類タスクで各クラス最低1グループが inner valid に含まれる
- グループ数 < 4 でフォールバック発動 + 警告

**Edge case:**
- cutoffs 1つ → 1時間 fold × n_splits
- 全ユーザーが全期間に存在 → 除外多、正常動作
- valid 期間にデータがないユーザー → 正常動作
- min_train_rows / min_valid_rows 未満 → fold スキップ + 警告
