Day 101 — NestedCV — チューニングとCV評価の分離

2026-07-22 青 / Phase 4 理論→コーディング Outer CV / Inner CV / 選択バイアス

📚 背景知識(読んでから問題へ)

🔵
Day 100の最後(タスク5)で「Optunaで見つけた最良のkを、そのkで作った特徴量自身のCVスコアで評価している」ことの問題を指摘しました。今日はこの問題を正面から扱います。
💡
結論: 「ハイパーパラメータを選ぶ」ことと「その性能を評価する」ことを、同じCV分割の上で行ってはいけません。模擬試験で勉強法を選び、同じ模擬試験の点数を「本当の実力」と報告するのが不公正なのと同じです。まだ見ていない本番試験=外側テストFoldで測る必要があります。
用語直感的な意味
外側CV(Outer CV)最終的な汎化性能を測るための分割。ここで作られたテストFoldはチューニング過程で一切参照しない
内側CV(Inner CV)外側の訓練データの中だけで行う、ハイパーパラメータ探索用の分割
選択バイアス(optimism bias)同じデータでチューニングと評価を両方行うと、報告スコアが実際の汎化性能より楽観的になる現象
入れ子構造(nested)外側Foldの内部で、さらに独立した内側Foldを回す二重構造。計算コストは「外側分割数×内側での学習回数」倍に増える

🧩 NestedCVの3つの原則

分離の原則

🔒 外側テストは内側CVから見えない

チューニングと評価の完全分離

外側テストFoldの行番号は、内側CVの探索処理のスコープに一切入らない。構造的にリークが起こり得ない。

入れ子構造

🪆 Foldごとに独立した内側CV

ベストkはFoldごとに変わってよい

外側5Foldそれぞれで、外側訓練データの中だけを使って独立に内側CVを回し、ベストなkを選び直す。

コストとの妥協

⚖️ 実務では簡略版で運用

原則は守り、規模は妥協する

フルNestedCVは高コスト。train/valid/testの単純な3分割でも「分離の原則」さえ守れば実務上は十分なことが多い。

🎯 問題

Day 076・Day 100と全く同じ合成データ(House Prices風、Neighborhood 25種類、Landmrkは出現数1件)を使います。今日はDay 100の「Optunaで探したkのCVスコアをそのまま報告する」やり方(素朴な1段階CV)と、NestedCVで測った公正な性能推定値を直接比較します。

使用データDay076・Day100と完全同一 Neighborhood 25種(Landmrk n=1)
import numpy as np
import pandas as pd
from sklearn.model_selection import KFold
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_squared_error

np.random.seed(42)
neighborhoods = ['NridgHt', 'Crawfor', 'Mitchel', 'Somerst', 'NWAmes',
                 'OldTown', 'BrkSide', 'Sawyer', 'NAmes', 'SawyerW',
                 'IDOTRR', 'MeadowV', 'Edwards', 'Timber', 'Gilbert',
                 'StoneBr', 'ClearCr', 'NPkVill', 'Blmngtn', 'BrDale',
                 'SWISU', 'Blueste', 'Greens', 'GrnHill', 'Landmrk']

# base_prices(各地区の真のベース価格)・counts(出現数、Landmrkは1件)はDay076・Day100と同一

rows = []
for nbr, cnt, base in zip(neighborhoods, counts, [base_prices[n] for n in neighborhoods]):
    prices = np.random.normal(base, base * 0.15, cnt)
    prices = np.clip(prices, 50000, 700000)
    rows.extend([(nbr, p) for p in prices])

df = pd.DataFrame(rows, columns=['Neighborhood', 'SalePrice'])
df = df.sample(frac=1, random_state=42).reset_index(drop=True)
カラム名意味
Neighborhood住宅地区名(25種類、Landmrkは出現数1件)category
SalePrice住宅価格(円)float

タスク

1
【理論】「同じCV分割でkを選び、そのCVスコアをそのまま性能として報告する」ことがなぜ楽観的バイアスを生むのか、外側CV・内側CVがそれぞれ何のためにあるのかを説明する
2
【実装】Day 100のsmoothed_loo_teを再利用し、「訓練統計だけでまだ見ていないholdoutにSmoothed TEを適用する」fit_apply_smoothed_te(train_df, holdout_df, col, target, k)を実装する
3
【実装】k候補[1,3,10,30,100,300,1000]に対し、外側5-Fold・内側3-FoldのNestedCVを実装し、5つのRMSEの平均を「NestedCVスコア」とする
4
【実装/分析】同じk候補・同じ分割で「内側CVを挟まず、そのままベストkのCVスコアを報告する」素朴な1段階CVも実装し、NestedCVスコアとのギャップを確認する
5
【分析/戦略】外側Foldごとに選ばれたベストkのばらつきを確認し、実務でフルNestedCVを使うことの計算コスト問題と現実的な妥協案を考察する

🪆 外側CV × 内側CVの入れ子構造

外側テストFold(赤)は、内側CV(緑の内側の箱)の処理に一切登場しない。この「見えない」状態を守ることが、NestedCVが公正である理由のすべて。

外側5-Foldのうち1回分の流れ(これを5回繰り返して平均する) ① 外側CV: データを5分割し、1つを Outer Test(テスト用)、残り4つを Outer Train(訓練用)にする Outer Test は、これ以降どのステップからも一度も参照しない ② 内側CV(Outer Train の中だけで完結) Inner Fold A Inner Fold B Inner Fold C k候補 [1,3,10,30,100,300,1000] ごとに3-FoldのRMSEを平均 → 最もRMSEが小さいkを「このOuter Foldのベストk」として採用 Outer Test (内側CVの どの処理からも 一切見えない) 🚫 ③ 選ばれたkで Outer Train 全体を学習する (このモデルはまだ Outer Test を一度も見ていない) ④ 初めて Outer Test を予測し、RMSEを記録する(=公正な性能推定値)

📊 NestedCVスコア vs 素朴な1段階CV(イメージ)

実際の数値は乱数・データに依存するため、ここでは相対的な傾向のイメージを示す。素朴な1段階CVはRMSEが小さく(良く)見えるが、これは選択バイアスによる楽観であり信用できない。

素朴な1段階CV(ベストkのスコア)
楽観的に見える
⚠ 選択バイアス混入
NestedCVスコア(外側5Foldの平均)
公正な推定値
信頼できる評価

外側Foldごとに選ばれるベストkの例(イメージ): [30, 100, 30, 10, 100] — ばらつきが大きいほど、そのハイパーパラメータの選択自体が不安定であることを示唆する。

💡 ヒント

ヒント1方向性

Day 100までは「1段階」のCVしかなかった。Optunaがkを探すのも、最終スコアを報告するのも同じKFoldの同じ5分割だった。今日はこの「1段階」を「外側」と「内側」の2段階に分ける。外側Foldの訓練データを、さらに内側でCVすると考えればよい。「外側テストデータには、内側CVのどのステップからも絶対に触れさせない」という制約さえ守れば、あとはDay 076・Day 100で書いたコードの組み合わせ。

ヒント2アプローチ
  • fit_apply_smoothed_te(train_df, holdout_df, col, target, k): train_df.groupby(col)[target].agg(['mean','count'])で統計を作り、(count*mean + k*global_mean)/(count+k)holdout_df[col].map(...)で貼り付ける(未出現カテゴリはglobal_meanで埋める)
  • 外側訓練データ自身の特徴量にはDay 100のsmoothed_loo_te(Leave-One-Out)を使う。自分自身の値を使わないようにするため
  • 内側CV: 外側訓練データをKFold(n_splits=3)でさらに分割し、各k候補の3Fold平均RMSEを比較して最小のkを選ぶ
  • 外側CV: 内側CVで選んだkで外側訓練データ全体を学習 → 外側テストデータにはfit_apply_smoothed_teでエンコード+予測 → RMSE
  • 素朴な1段階CVは、外側CVと同じKFold分割を使うが、内側CVを挟まずFold内で選んだkのスコアをそのまま性能とする点が違う
ヒント3コード骨格
def smoothed_loo_te(y, group_sum, group_count, global_mean, k):
    return (group_sum - y + k * global_mean) / (group_count - 1 + k)

def fit_apply_smoothed_te(train_df, holdout_df, col, target, k):
    stats = train_df.groupby(col)[target].agg(['mean', 'count'])
    global_mean = train_df[target].mean()
    smoothed_map = (stats['count'] * stats['___'] + k * global_mean) / (stats['count'] + ___)
    return holdout_df[col].map(smoothed_map).fillna(___).values

def inner_cv_best_k(train_df, col, target, k_candidates, n_inner_splits=3, random_state=0):
    inner_kf = KFold(n_splits=___, shuffle=True, random_state=random_state)
    mean_scores = {}
    for k in k_candidates:
        fold_rmses = []
        for tr_idx, val_idx in inner_kf.split(train_df):
            # tr_foldはLOO、val_foldはfit_apply_smoothed_teでエンコード
            ...
        mean_scores[k] = np.mean(fold_rmses)
    best_k = min(mean_scores, key=mean_scores.get)
    return best_k, mean_scores

outer_kf = KFold(n_splits=5, shuffle=True, random_state=1)
outer_scores, outer_best_ks = [], []
for outer_tr_idx, outer_te_idx in outer_kf.split(df):
    outer_train = df.iloc[outer_tr_idx].reset_index(drop=True)
    outer_test = df.iloc[outer_te_idx].reset_index(drop=True)
    best_k, _ = inner_cv_best_k(outer_train, 'Neighborhood', 'SalePrice', ___)
    outer_best_ks.append(best_k)
    # best_kで外側訓練データ全体を学習し、外側テストデータで評価
    ...

模範解答

import numpy as np
import pandas as pd
from sklearn.model_selection import KFold
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_squared_error

# ====== データ準備(Day076・Day100と完全同一) ======
np.random.seed(42)
# neighborhoods / base_prices / counts はDay076・Day100と同一(省略)

rows = []
for nbr, cnt, base in zip(neighborhoods, counts, [base_prices[n] for n in neighborhoods]):
    prices = np.random.normal(base, base * 0.15, cnt)
    prices = np.clip(prices, 50000, 700000)
    rows.extend([(nbr, p) for p in prices])

df = pd.DataFrame(rows, columns=['Neighborhood', 'SalePrice'])
df = df.sample(frac=1, random_state=42).reset_index(drop=True)

K_CANDIDATES = [1, 3, 10, 30, 100, 300, 1000]

# ====== タスク2: 訓練統計だけでholdoutをエンコードする関数 ======
def smoothed_loo_te(y, group_sum, group_count, global_mean, k):
    """外側/内側 訓練データ“自身”の特徴量を作るときに使う(Day100と同じ関数)"""
    return (group_sum - y + k * global_mean) / (group_count - 1 + k)

def fit_apply_smoothed_te(train_df, holdout_df, col, target, k):
    """train_dfの統計だけを使い、holdout_dfにSmoothed TEを適用する。holdoutの情報は一切使わない"""
    stats = train_df.groupby(col)[target].agg(['mean', 'count'])
    global_mean = train_df[target].mean()
    smoothed_map = (stats['count'] * stats['mean'] + k * global_mean) / (stats['count'] + k)
    return holdout_df[col].map(smoothed_map).fillna(global_mean).values

def rmse_for_k(train_df, holdout_df, col, target, k):
    """train_dfでLOOエンコード→LinearRegression学習→holdout_dfで評価しRMSEを返す共通処理"""
    tr_group_sum = train_df.groupby(col)[target].transform('sum')
    tr_group_count = train_df.groupby(col)[target].transform('count')
    tr_global_mean = train_df[target].mean()
    tr_te = smoothed_loo_te(train_df[target], tr_group_sum, tr_group_count, tr_global_mean, k)
    ho_te = fit_apply_smoothed_te(train_df, holdout_df, col, target, k)

    model = LinearRegression().fit(tr_te.values.reshape(-1, 1), train_df[target].values)
    pred = model.predict(ho_te.reshape(-1, 1))
    return np.sqrt(mean_squared_error(holdout_df[target].values, pred))

# ====== タスク3: NestedCV(外側5-Fold × 内側3-Fold) ======
def inner_cv_best_k(train_df, col, target, k_candidates, n_inner_splits=3, random_state=0):
    inner_kf = KFold(n_splits=n_inner_splits, shuffle=True, random_state=random_state)
    mean_scores = {k: [] for k in k_candidates}
    for tr_idx, val_idx in inner_kf.split(train_df):
        inner_train = train_df.iloc[tr_idx].reset_index(drop=True)
        inner_val = train_df.iloc[val_idx].reset_index(drop=True)
        for k in k_candidates:
            mean_scores[k].append(rmse_for_k(inner_train, inner_val, col, target, k))
    mean_scores = {k: np.mean(v) for k, v in mean_scores.items()}
    best_k = min(mean_scores, key=mean_scores.get)
    return best_k, mean_scores

outer_kf = KFold(n_splits=5, shuffle=True, random_state=1)
outer_scores, outer_best_ks = [], []

for outer_tr_idx, outer_te_idx in outer_kf.split(df):
    outer_train = df.iloc[outer_tr_idx].reset_index(drop=True)
    outer_test = df.iloc[outer_te_idx].reset_index(drop=True)

    # 内側CV: 外側テストデータには一切触れず、外側訓練データの中だけでkを選ぶ
    best_k, _ = inner_cv_best_k(outer_train, 'Neighborhood', 'SalePrice', K_CANDIDATES)
    outer_best_ks.append(best_k)

    # 選ばれたkで外側訓練データ全体を学習し、初めて外側テストデータで評価する
    rmse = rmse_for_k(outer_train, outer_test, 'Neighborhood', 'SalePrice', best_k)
    outer_scores.append(rmse)

nested_cv_score = np.mean(outer_scores)

print("--- タスク3: NestedCV ---")
print(f"外側Foldごとの選択k : {outer_best_ks}")
print(f"NestedCVスコア(平均): {nested_cv_score:,.0f}")

# ====== タスク4: 素朴な1段階CV(内側CVを挟まず、そのままベストkのスコアを報告) ======
naive_kf = KFold(n_splits=5, shuffle=True, random_state=1)  # 外側CVと同じ分割方法(比較を公平にするため)
naive_scores = {k: [] for k in K_CANDIDATES}

for tr_idx, te_idx in naive_kf.split(df):
    tr_fold = df.iloc[tr_idx].reset_index(drop=True)
    te_fold = df.iloc[te_idx].reset_index(drop=True)
    for k in K_CANDIDATES:
        naive_scores[k].append(rmse_for_k(tr_fold, te_fold, 'Neighborhood', 'SalePrice', k))

naive_mean_scores = {k: np.mean(v) for k, v in naive_scores.items()}
naive_best_k = min(naive_mean_scores, key=naive_mean_scores.get)
naive_best_rmse = naive_mean_scores[naive_best_k]  # ←これを「性能」として報告するのが素朴なやり方

print("\n--- タスク4: 素朴な1段階CV ---")
print(f"素朴な1段階CVのベストスコア: {naive_best_rmse:,.0f}")
print(f"NestedCVスコア           : {nested_cv_score:,.0f}")
gap = nested_cv_score - naive_best_rmse
print(f"ギャップ(楽観バイアスの目安): {gap:,.0f}")

出力イメージ(乱数依存のため相対的な傾向として見る):

--- タスク3: NestedCV ---
外側Foldごとの選択k : [30, 100, 30, 10, 100]
NestedCVスコア(平均): 1x,xxx

--- タスク4: 素朴な1段階CV ---
素朴な1段階CVのベストスコア: 1x,xxx
NestedCVスコア           : 1x,xxx
ギャップ(楽観バイアスの目安): xxx  ← NestedCVの方がRMSEが大きい=素朴な方法は楽観的だった
🧠
理論問題(タスク1): 素朴な1段階CVでは7つのk候補すべてを同じ5-Fold分割で試し、最小のRMSEをそのまま「性能」として報告する。しかし複数候補から最良のものを選ぶ操作自体が、平均的に本来の実力より良く見えるように偏らせる(選択バイアス)。NestedCVはこの「選ぶ」処理と「評価する」処理を物理的に別データで行う。外側テストFoldは内側CV(kを選ぶ処理)から一度も参照されないため、そこで測るRMSEには選択バイアスが混入しない。
🧠
分析・戦略問題(タスク5): 外側Foldごとに選ばれるベストkは分割のされ方によって変動する。ばらつきが大きいほど、kという設定自体がデータの中のどの部分を訓練に使うかに左右される不安定なハイパーパラメータであることを意味する。一方でフルNestedCVは「外側Fold数×内側Fold数×k候補数」倍のモデル学習が必要で計算コストが非常に高い。実務では①train/valid/testの単純な3分割(validでチューニング、testは最終評価のみ1回)、②Optunaのn_trialsを絞る、③複数シードでCVスコアの分散を確認する、といった妥協が使われる。それでも「チューニングに使ったデータと最終評価に使うデータを分ける」というNestedCVの原則そのものは必ず守るべき。

🪜 Step-by-Step 解説

1「訓練統計だけでholdoutを塗る」関数を独立させる

def fit_apply_smoothed_te(train_df, holdout_df, col, target, k):
    stats = train_df.groupby(col)[target].agg(['mean', 'count'])
    global_mean = train_df[target].mean()
    smoothed_map = (stats['count'] * stats['mean'] + k * global_mean) / (stats['count'] + k)
    return holdout_df[col].map(smoothed_map).fillna(global_mean).values
🔑
なぜこうするか: NestedCVでは「訓練データ」と「まだ見ていないデータ」の境界が、外側Foldと内側Foldの両方で何度も登場する。境界をまたぐたびに同じ処理を書くのは非効率かつミスの元なので、Day 076の考え方を1つの関数に切り出す。これにより外側テストへの適用も内側検証への適用も、同じ1行の関数呼び出しで済む。

2訓練データ自身のエンコードにはLOOを使う

tr_te = smoothed_loo_te(train_df[target], tr_group_sum, tr_group_count, tr_global_mean, k)
🧪
なぜこうするか: fit_apply_smoothed_teはholdout用。訓練データ自身にこれと同じ単純平均を使うと、各行が自分自身のyを含む統計でエンコードされ、ナイーブ実装のリークがモデル学習側に紛れ込む。訓練データ自身にはDay 100のLOO(自分だけを除く)を使うことで、外側・内側どちらの階層でも一貫してリークのない特徴量を作れる。

3内側CVで「外側テストを一切見ずに」kを選ぶ

def inner_cv_best_k(train_df, col, target, k_candidates, n_inner_splits=3, random_state=0):
    inner_kf = KFold(n_splits=n_inner_splits, shuffle=True, random_state=random_state)
    ...
⚖️
なぜこうするか: この関数に渡されるtrain_dfは、外側CVで既にテストFoldを除外した後の「外側訓練データ」だけ。関数の中でこのデータをさらに3分割し、その中だけでk候補を比較する。外側テストFoldの行番号はこの関数のスコープに一切入らないため、「外側テストを見てチューニングしてしまう」ミスが構造的に起こり得ない。

4外側ループで「選ぶ→学習する→初めて見る」を1セットにする

for outer_tr_idx, outer_te_idx in outer_kf.split(df):
    outer_train = df.iloc[outer_tr_idx].reset_index(drop=True)
    outer_test = df.iloc[outer_te_idx].reset_index(drop=True)
    best_k, _ = inner_cv_best_k(outer_train, 'Neighborhood', 'SalePrice', K_CANDIDATES)
    rmse = rmse_for_k(outer_train, outer_test, 'Neighborhood', 'SalePrice', best_k)
🧪
なぜこうするか: 順番が重要。①outer_testを切り分ける、②outer_trainの中だけでbest_kを決める、③そのbest_kouter_train全体を学習し、④最後に一度だけouter_testを予測に使う。この順番を守る限り、outer_testは「モデルが一度も調整の材料にしていない初見データ」であり続ける。

5素朴な1段階CVと並べて、ギャップを可視化する

gap = nested_cv_score - naive_best_rmse
🔑
なぜこうするか: 同じデータ・同じk候補・似た分割方法を使いながら、「内側CVを挟むかどうか」だけを変えた2つのスコアを並べることで、選択バイアスの大きさを具体的な数字として体感できる。このギャップは「素朴な方法を使ったとき、報告スコアがどれくらい話を盛っているか」の目安になる。

🧮 数学・統計の補足(文系向け)

📐
NestedCVを直感で: 資格試験の予想問題集を10冊買い、それぞれで模擬試験を受けたとする。一番良かった1冊の点数を「自分の実力」として報告するのはズルい。10冊の中には「たまたま相性が良かった」1冊が混ざっているから。正しくは、一番良さそうな「勉強のやり方」を選んだうえで、まだ解いていない本番の過去問で実力を測るべき。NestedCVの外側テストFoldは、この「まだ解いていない過去問」にあたる。
📏
選択バイアスを直感で: サイコロを7個振って、一番大きい目が出たサイコロを「6の目が出やすいサイコロ」と言うようなもの。1個だけ振れば平均3.5に近づくが、7個の中の最大値だけを見れば平均よりずっと大きい数字が選ばれやすくなる。CVスコアで複数のkを比較して最小値を選ぶのも、これと同じ「複数の中の最良値」を見ている操作。

数式を見たら

NestedCV Score = (1/K) Σᵢ RMSE( f_{k*ᵢ}(Trainᵢ), Testᵢ )

「i番目の外側Foldでは、その訓練データTrainᵢの中だけで選んだベストなk*ᵢを使ってモデルfを作り、一度も使っていないテストデータTestᵢで誤差を測る。それをK個のFold分平均する」という意味。k*ᵢの添字がi(Foldごとに違ってよい)になっている点が、単純な「1つのkを全体で使う」CVとの違い。

🏆 Kaggleでの実践的な使い方

よく使われるコンペカテゴリ: ☑ 表形式データ(Tabular) / ☐ 自然言語処理(NLP) / ☐ 画像認識(CV) / ☐ 時系列(Time Series)

  • 上位解法の「CV戦略」セクション: Kaggleの上位解法解説記事では必ず「どうCVを組んだか」が説明される。強いチームほど「ハイパーパラメータ探索用のCV」と「モデル選択・最終アンサンブル評価用のCV」を意識的に分けている記述が見られる
  • cross_val_score + 手動の外側ループ: sklearnには専用のNestedCV関数はないが、内側CVのobjective関数の中でcross_val_scoreを呼び出し、外側は自前のforループで回すのが定番の書き方(今日のコードもこのパターン)
  • Optunaでの実務的な妥協: フルNestedCVは重すぎるため、実務では「train/validで探索、held-out testで1回だけ最終確認」という簡略版がよく使われる。これは「内側CV1本・外側Fold1本」に相当するNestedCVの最小構成と見ることができる
  • 時系列コンペでの重要性: 時系列データ(Day 099のRolling/Lag特徴量が絡むコンペ)では、外側・内側のいずれも「未来のデータで訓練しない」時系列分割(TimeSeriesSplit)にする必要があり、通常のKFold以上にリーク対策が絡み合う

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
Optunaで探索したCVスコアがそのままモデルの実力だと思い込む「CVで評価している」時点で安全だと錯覚する探索に使ったCVスコアには選択バイアスが乗っている。真に公正な評価にはチューニングに使っていないデータでの再評価が必要
内側CVと外側CVで同じ乱数シード・同じ分割を使ってしまう「同じKFold設定を使い回せば楽」と考える外側テストFoldの行が内側CVの学習・検証に混ざると、外側テストが「初見データ」でなくなりNestedCVの意味がなくなる
NestedCVスコアが素朴な1段階CVより悪く見えると「実装が間違っている」と思う数字が下がる(誤差が増える)とバグを疑いたくなるNestedCVスコアの方が悪く見えるのはむしろ正常。素朴な方法が本来より楽観的だったことが可視化されただけ
常にフルNestedCVを使うべきだと考える「公正な方法が常に正解」という思い込み計算コストが非常に高いため、実務では計算コストと公正さのバランスを取った簡略版を使うのが一般的
k候補を1つの外側Foldでしか選ばず、他のFoldにも使い回す内側CVを1回だけ回せば十分だと考える外側Foldごとにデータの中身が変わるため、ベストなkもFoldごとに変わりうる。各外側Foldで独立に内側CVを回すのがNestedCVの定義

🚀 次のステップ

  • 発展: 内側CVの中身をグリッドサーチではなくOptuna(n_trials=20程度)に置き換え、より広いk候補・計算コストの増加を体感してみる
  • 次回予告: Day 102「アンサンブル①(Averaging)」 — 複数モデルの予測を組み合わせるSimple Average・Rank Average・重み最適化。今日まで1つのモデルの中でのチューニングを厳密化してきましたが、次は「複数のモデルをどう組み合わせるか」という新しいテーマに進みます

📝 自己評価(解いた後に記入)

自分の回答・気づき・メモ: