0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

自分でつくるミニGPT ── NumPyだけで書いたミニ言語モデル

0
Last updated at Posted at 2026-08-31

20260830-1650.png

「GPTはLLM(大規模言語モデル)です。LLMはTransformerというアーキテクチャで動いています。Transformerとは文脈を理解する仕組みです」 と言われても抽象的すぎてピンとこないですよね。

これは、自分の手を動かしながらそんなモヤモヤを解消していけるテキストです。

AIの正体見たり枯れarray( ᐛ )

実際に動くTransformer型の言語モデルを、フレームワークなし・NumPyだけで1から書いたコードです。10単語だけの小さな日本語で学習させ、Attentionが何を学ぶのかを実際に見て確かめます。

説明はあとにして、実際に動いているところを見てみましょう。

(venv) mini_transformer$ python3 predict_cli.py 今日 は

入力: 今日 は
次に来そうな単語:
  いい    70.5%  █████████████████████
  わるい   22.1%  ███████
  雨      7.0%  ██
  昨日     0.1%
  明日     0.1%
  も      0.1%
  今日     0.1%
  が      0.1%
  は      0.1%
  天気     0.1%

(venv) mini_transformer$ python3 predict_cli.py --generate 今日 は

入力: 今日 は
  → 「いい」を追加(確信度 70.5%)
  → 「天気」を追加(確信度 99.8%)
生成結果: 今日 は いい 天気

(venv) mini_transformer$ python3 predict_cli.py --generate 明日 も

入力: 明日 も
  → 「いい」を追加(確信度 45.5%)
  → 「天気」を追加(確信度 99.8%)
生成結果: 明日 も いい 天気

(venv) mini_transformer$ python3 predict_cli.py --generate 昨日 が

入力: 昨日 が
  → 「わるい」を追加(確信度 42.0%)
  → 「天気」を追加(確信度 99.8%)
生成結果: 昨日 が わるい 天気

(venv) mini_transformer$ python3 predict_cli.py
==================================================
mini_transformer 対話モード
使える単語: 今日 明日 昨日 は が も いい わるい 雨 天気
空白区切りで単語を入力してください(例: 今日 は)
先頭に 'g ' を付けると、文が完成するまで自動生成します(例: g 今日 は)
終了するには exit / quit / q
==================================================

> g 天気 が わるい

入力: 天気 が わるい
  → 「天気」を追加(確信度 99.8%)
生成結果: 天気 が わるい 天気

> g 明日 が

入力: 明日 が
  → 「いい」を追加(確信度 36.4%)
  → 「天気」を追加(確信度 99.8%)
生成結果: 明日 が いい 天気

> exit
終了します。

当然おかしなこともいいます。

学習工程も見てみます。

(venv) mini_transformer$ python3 mini_transformer.py
[font] 日本語フォント 'IPAexGothic' を使用します。
=== 1. コーパスを作る ===
コーパス文数: 100
学習サンプル数(文の途中から次の単語を当てる問題の数): 163

=== 2. モデルを初期化する ===
パラメータ数の目安: 914 個 (Embedding 80 + Attention 192 + FFN 552 + 出力層 90)

=== 3. 学習前の予測(まだ何も学んでいないのでデタラメなはず) ===
入力: 今日 は → 雨(14.2%) 今日(12.4%) わるい(12.1%) いい(11.3%) も(11.0%) ...

=== 4. 学習する ===
Epoch 1/1200  Loss: 1.0516  (lr=0.1500)
Epoch 50/1200  Loss: 0.7695  (lr=0.1500)
Epoch 100/1200  Loss: 0.8255  (lr=0.1500)
Epoch 150/1200  Loss: 0.8194  (lr=0.1500)
Epoch 200/1200  Loss: 0.8019  (lr=0.1500)
Epoch 250/1200  Loss: 0.8011  (lr=0.1500)
Epoch 300/1200  Loss: 0.6946  (lr=0.0750)
Epoch 350/1200  Loss: 0.6749  (lr=0.0750)
Epoch 400/1200  Loss: 0.6670  (lr=0.0750)
Epoch 450/1200  Loss: 0.6864  (lr=0.0750)
Epoch 500/1200  Loss: 0.6791  (lr=0.0750)
Epoch 550/1200  Loss: 0.6784  (lr=0.0750)
Epoch 600/1200  Loss: 0.6371  (lr=0.0375)
Epoch 650/1200  Loss: 0.6499  (lr=0.0375)
Epoch 700/1200  Loss: 0.6386  (lr=0.0375)
Epoch 750/1200  Loss: 0.6493  (lr=0.0375)
Epoch 800/1200  Loss: 0.6489  (lr=0.0375)
Epoch 850/1200  Loss: 0.6428  (lr=0.0375)
Epoch 900/1200  Loss: 0.6195  (lr=0.0187)
Epoch 950/1200  Loss: 0.6161  (lr=0.0187)
Epoch 1000/1200  Loss: 0.6249  (lr=0.0187)
Epoch 1050/1200  Loss: 0.6186  (lr=0.0187)
Epoch 1100/1200  Loss: 0.6237  (lr=0.0187)
Epoch 1150/1200  Loss: 0.6280  (lr=0.0187)
Epoch 1200/1200  Loss: 0.6129  (lr=0.0094)

=== 5. 学習後の予測:うまくいく例(学習データにある語順) ===
入力: 今日 は → いい(70.5%) わるい(22.1%) 雨(7.0%) 昨日(0.1%) 明日(0.1%) ...
入力: 明日 も いい → 天気(99.8%) は(0.0%) 今日(0.0%) も(0.0%) が(0.0%) ...
入力: 昨日 が → わるい(42.0%) 雨(34.6%) いい(23.0%) は(0.1%) が(0.1%) ...

=== 6. 学習後の予測:変な入力(学習データにない語順) ===
入力: 雨 は → いい(44.1%) わるい(37.9%) 雨(17.9%) 明日(0.0%) 昨日(0.0%) ...
入力: 天気 天気 → いい(41.3%) わるい(33.1%) 雨(25.4%) 今日(0.0%) は(0.0%) ...
入力: いい 今日 → わるい(32.3%) いい(25.8%) 天気(21.5%) 雨(19.3%) 今日(0.2%) ...

=== 7. グラフを保存する ===
[保存] output/loss_curve.png
[保存] output/attention_heatmap.png
入力: 今日 は → いい(70.5%) わるい(22.1%) 雨(7.0%) 昨日(0.1%) 明日(0.1%) ...
[保存] output/prediction_bar.png
[保存] output/prob_evolution.png

=== 8. 学習済みモデルを保存する ===
[保存] output/model.npz(学習済みモデル)
predict_cli.py から読み込んで、自分で好きな単語を入力して試せます。
例: python predict_cli.py 今日 は

完了! output/ フォルダに画像と学習済みモデルが保存されました。

20260830-1650-prediction_bar.png

20260830-1650-prob_evolution.png

20260830-1650-loss_curve.png

20260830-1650-attention_heatmap.png

これは何か

  • 語彙はたった10単語(トークン):「今日・明日・昨日・は・が・も・いい・わるい・雨・天気」
  • この10単語で作れる自然な日本語文だけを100個集めたコーパス(お勉強用の言語データ)で学習する
  • タスクは「文の途中まで見て、次の単語を当てる」こと。これはGPTのようなLLMが内部でやっていることと原理的に同じ
  • Embedding、Positional Encoding、Self-Attention、Feed-Forward、残差接続、Layer Normalization、そして逆伝播(バックプロパゲーション)まで、全部NumPyの行列演算で手書きしている(PyTorch/TensorFlowは不使用)
  • 学習の様子、予測の様子、Attention重みをグラフに可視化して、「モデルが何を見て予測しているか」を実際の数字と絵で確認できる
  • 学習が終わったモデルは output/model.npz に保存され、predict_cli.pyから読み込んで自分で好きな単語を入力し、次の単語予測や文の自動生成をその場で試せる

必要な準備

ここからダウンロードまたは git clone。

git clone https://gitlab.com/Ayanonymous/mini_transformer.git
cd mini_transformer
python3 -m venv venv
source ./venv/bin/activate
pip install numpy matplotlib

Python 3.9以降を想定。CPUだけで数秒〜数十秒で学習が終わります。

使い方

1. 学習する

python3 mini_transformer.py

これだけです。実行すると、

  1. コーパス(100文)と学習データを作る
  2. モデルを初期化する
  3. 学習前の予測(デタラメな確率分布のはず)を表示する
  4. 学習を実行し、エポックごとの損失をログに出す
  5. 学習後の予測を、「うまくいく例」と「学習データにない変な入力」の両方で確認する
  6. output/ フォルダに4枚のグラフと、学習済みモデル(model.npz)を保存する

という流れがターミナルに表示されます。乱数シードを固定しているので、何度実行しても同じ結果が出ます。

2. 自分で入力して試す(predict_cli.py)

学習が終わったら、保存されたモデルを読み込んで対話的に試せます(学習はもうしないので一瞬で起動します)。

# 対話モード(何も引数を渡さずに実行)
python3 predict_cli.py

# ワンショットモード:次の1単語の予測だけ見て終了
python3 predict_cli.py 今日 は

# 自動生成モード:文が完成するまで単語を続けて生成する
python3 predict_cli.py --generate 今日 は

対話モードでは、行の先頭に g を付けると自動生成モードになります
(例: g 今日 は)。exit / quit / q で終了。
使える単語は、今日・明日・昨日・は・が・も・いい・わるい・雨・天気の10個だけで、それ以外の単語を入力するとエラーになります(このモデルが知っているのは学習コーパスの10単語だけです)。

対話モードの実行例:

> 今日 は
入力: 今日 は
次に来そうな単語:
  いい    70.5%  █████████████████████
  わるい   22.1%  ███████
  雨      7.0%  ██
  ...

> g 昨日 が
入力: 昨日 が
  → 「わるい」を追加(確信度 42.0%)
  → 「天気」を追加(確信度 99.8%)
生成結果: 昨日 が わるい 天気

出力されるファイル(output/ フォルダ)

ファイル 内容
loss_curve.png 学習曲線。エポックが進むにつれて損失(予測の悪さ)が下がっていく様子
attention_heatmap.png 「今日 は いい」という入力に対するAttention重みを、学習前・学習後で並べて比較
prediction_bar.png 「今日 は」の次に来る単語の予測確率を棒グラフで表示
prob_evolution.png 「今日 は」の次単語予測確率が、学習の進行とともにどう変化するか
model.npz 学習済みパラメータ。predict_cli.py がここから読み込む

実行結果の例

デフォルト設定(1200エポック)で実行すると、だいたいこのような結果になります(乱数シード固定なので毎回ほぼ同じ数字が出ます)。

Epoch 1/1200     Loss: 1.0516  (lr=0.1500)
Epoch 600/1200   Loss: 0.6371  (lr=0.0375)
Epoch 1200/1200  Loss: 0.6129  (lr=0.0094)

=== 学習後の予測:うまくいく例 ===
入力: 今日 は     → いい(70.5%) わるい(22.1%) 雨(7.0%) ...
入力: 明日 も いい → 天気(99.8%) ...
入力: 昨日 が     → わるい(42.0%) 雨(34.6%) いい(23.0%) ...

=== 学習後の予測:変な入力 ===
入力: 雨 は   → いい(44.1%) わるい(37.9%) 雨(17.9%) ...
入力: 天気 天気 → いい(41.3%) わるい(33.1%) 雨(25.4%) ...

「今日は」→ 高確率で「いい」(今日は晴れやすいという設計どおり)、「昨日が」→ 「わるい」「雨」に確率が寄る(昨日は悪天候寄りという設計どおり)、という具合に、主語に応じて予測が変わっていることが確認できます。
一方で学習データにない語順(「雨は」「天気天気」)を入力すると、どのトークンが最有力か決めきれず確率が割れる、いわゆる「迷走」した分布になります。これは「学習データにない文は処理できない=コーパスの質が全て」というLLMの限界そのものです。

学習率は LEARNING_RATE(初期値)を LR_DECAY_EVERY エポックごとにLR_DECAY_FACTOR 倍していくstep decayで下げています。これがないと、損失がある水準からあまり下がらず、細かく振動し続けてしまいます(詳しくは train() 関数のコメントを参照)。

コーパスの設計:なぜ主語ごとに天気の傾向を変えているか

build_corpus() は100文を1つずつ手で書く代わりに、頻度に偏りをつけた組み合わせ生成で自然な文の分布を再現しています。ポイントは、主語(今日・明日・昨日)ごとに天気の出やすさを変えていることです。

  • 「今日」は晴れやすい(「いい」に偏る)
  • 「明日」は五分五分(予報の不確実さ)
  • 「昨日」は悪天候だった、という設定(「わるい」「雨」に偏る)

もし全ての主語が同じ傾向だったら、天気の予測は直前の助詞・形容詞だけで決まってしまい、Attentionがわざわざ主語を見に行く理由がなくなります。
主語ごとに傾向を変えることで初めて、「助詞や形容詞が、少し離れた位置にある主語の情報を参照しないと正しく予測できない」という、Attentionが本当に必要になる状況を作っています。

attention_heatmap.png の読み方で1つ注意点があります。損失(学習の手がかり)に直接効くのは常に最後のトークン位置のQueryだけです。
途中の位置は「最後の位置が参照するKey/Value」としてしか関わらず、その位置自身のQueryは間接的にしか学習されません。なので一番下の行(今回の例なら「いい」の行)が一番素直に「学習した注目パターン」を表しています。実際に学習後は、どの行から見ても主語「今日」の列への注目が学習前(一様に約33%)より底上げされているのが確認できます。

モデルの構造

トークンID列
   │
   ▼
Embedding(10×8)+ Positional Encoding(sin/cos)
   │
   ▼
Self-Attention(Q, K, V を8×8の重み行列で作る、1ヘッド)
   │  scores = QKᵀ/√d_k → softmax → 重み付き和
   ▼
残差接続(+ 元の入力)→ Layer Normalization
   │
   ▼
Feed-Forward Network(8 → 32 → 8、ReLU)
   │
   ▼
残差接続(+ 直前の出力)→ Layer Normalization
   │
   ▼
出力層(最後のトークン位置の文脈ベクトル → 10語彙への線形変換 → softmax)
   │
   ▼
次の単語の確率分布

パラメータ数は914個(Embedding 80、Attention 192、FFN 552、出力層 90)。
GPT-4クラスのモデル(パラメータ数千億)と比べると原子1個と地球くらい規模が違いますが、「Attentionで文脈を見て次のトークンを予測する」という原理は同じです。

このコードで省略していること

小さく・読める規模に保つため、以下は意図的に省略しています。

  • Multi-Head Attention:視点を複数持つ拡張(ここではHead数1)
  • Decoder Mask(causal mask):本来は「未来のトークンを見せない」ためのマスクが必要ですが、この実装では学習データを常に「文の先頭からのprefix」として切り出しているため、そもそも未来のトークンが入力に含まれず、マスクなしでも同じ効果になっている
  • Dropout:過学習を防ぐための正則化。代わりにL2正則化(重み減衰)でパラメータの発散を防いでいる(後述)
  • BPEなどのサブワードトークナイザ:語彙が10単語しかないので単語=トークン

実装上の小さな工夫:なぜWeight Decayが要るのか

sgd_step() にL2正則化(weight decay)を入れています。理由はLayer Normalizationの性質にあります。LayerNormは「入力を丸ごと定数倍しても出力が変わらない」というスケール不変性を持つため、損失を変えずにパラメータのノルムだけがどこまでも大きくなっていく「使い道のない方向」が存在してしまいます。実際にweight decayなしで長く学習させると、Embeddingの値が際限なく発散し、Attentionが入力の違いを無視して「学習でよく出てきた単語」ばかりを返すようになってしまいます(=どんな入力を入れてもほぼ同じ予測になる)。
weight decayはパラメータを少しずつ0へ引き戻す力を加えることで、この発散にブレーキをかけ、Attentionが本当に入力の違いを見て判断するように学習を安定させます。

ファイル構成

  • mini_transformer.py … 学習の本体(コーパス生成・順伝播・逆伝播・学習ループ・可視化・モデル保存まで全部入り)
  • predict_cli.py … 学習済みモデルを読み込んで対話的に試すCLI(学習はしない。mini_transformer.pyの関数・語彙をそのまま再利用する)
  • output/ … 実行後に生成されるグラフ(PNG)と学習済みモデル(model.npz)。リポジトリには含まれません

リポジトリはGitLabで公開していますが、ソースコードもそのまま載せちゃいます。

"""
mini_transformer.py
====================

NumPyだけで書いた、Transformer言語モデルの最小実装。

- 語彙は10単語の日本語だけ(今日・明日・昨日・は・が・も・いい・わるい・雨・天気)
- Embedding → Positional Encoding → Attention → Feed-Forward → 出力層、まで
  順伝播・逆伝播(バックプロパゲーション)を全部NumPyの行列演算で手書きしている
- PyTorchやTensorFlowなどのフレームワークは一切使っていない
- 「文の途中まで見て、次の単語を当てる」タスクを解かせることで、
  ChatGPTのようなLLMが内部でやっていることの縮小版を体験できる

使い方:
    python3 -m venv venv
    source ./venv/bin/activate
    pip install numpy matplotlib
    python3 mini_transformer.py

実行すると、学習ログをターミナルに出しながら、
output/ フォルダに以下のファイルを保存する。
    - loss_curve.png        学習曲線(エポック vs 損失)
    - attention_heatmap.png Attention重みの学習前後比較
    - prediction_bar.png    予測確率の棒グラフ
    - prob_evolution.png    予測確率がエポックとともにどう変わるか
    - model.npz             学習済みパラメータ(predict_cli.pyが読み込む)

学習が終わったら、以下のCLIで自分で単語を入力して次単語予測や
文の自動生成をその場で試せる(学習し直さないので一瞬で起動する)。
    python3 predict_cli.py
    python3 predict_cli.py 今日 は
    python3 predict_cli.py --generate 今日 は

詳しい解説は README.md を参照。
"""

import os
import random

import numpy as np
import matplotlib
import matplotlib.pyplot as plt
import matplotlib.font_manager as fm

# ============================================================
# 0. 再現性のための乱数シード固定
# ============================================================
# 同じシードなら毎回同じ初期値・同じ学習順序になるので、
# 「このコードを動かすと必ずこのログとグラフが出る」を保証できる。
RANDOM_SEED = 42
np.random.seed(RANDOM_SEED)
random.seed(RANDOM_SEED)


# ============================================================
# 1. 日本語フォントの設定(matplotlib用)
# ============================================================
def setup_japanese_font():
    """
    matplotlibでグラフの日本語ラベルが文字化け(□□□、通称「トーフ」)
    しないようにフォントを設定する。

    使えるフォント名はOS・環境によって異なるので、よく使われる候補を
    順番に試して、システムにインストールされている最初の1つを採用する。
    もしグラフの日本語が全部トーフになる場合は、以下のどちらかで直る。
      1. お使いのOSに日本語フォント(例: Noto Sans CJK JP)をインストールする
      2. candidates リストの先頭に、手元の環境で使えるフォント名を追加する
    """
    candidates = [
        "IPAexGothic", "IPAGothic", "Noto Sans CJK JP", "Noto Sans JP",
        "Hiragino Sans", "Hiragino Kaku Gothic Pro", "Yu Gothic",
        "Meiryo", "TakaoPGothic", "MS Gothic", "VL PGothic",
    ]
    available = {f.name for f in fm.fontManager.ttflist}
    for name in candidates:
        if name in available:
            matplotlib.rcParams["font.family"] = name
            print(f"[font] 日本語フォント '{name}' を使用します。")
            return
    print(
        "[font] 日本語フォントが見つかりませんでした。グラフの日本語が文字化けする場合は、"
        "日本語フォントをインストールするか setup_japanese_font() の候補リストを編集してください。"
    )

matplotlib.rcParams["axes.unicode_minus"] = False  # 日本語フォント使用時のマイナス記号の文字化け対策


# ============================================================
# 2. 語彙とモデルのハイパーパラメータ
# ============================================================
VOCAB = ["今日", "明日", "昨日", "は", "が", "も", "いい", "わるい", "雨", "天気"]
token2id = {w: i for i, w in enumerate(VOCAB)}
id2token = {i: w for w, i in token2id.items()}

VOCAB_SIZE = len(VOCAB)   # 10
D_MODEL = 8                # Embeddingの次元数
D_K = D_MODEL              # Attentionのkey次元(Head数1なのでD_MODELと同じ)
D_FF = 32                  # Feed-Forwardの隠れ層の次元数
MAX_LEN = 8                # Positional Encodingを用意しておく最大系列長(余裕を持って8)

EPOCHS = 1200
LEARNING_RATE = 0.15        # 学習率の初期値(下記の減衰スケジュールで徐々に下げる)
LR_DECAY_EVERY = 300         # 何エポックごとに学習率を下げるか
LR_DECAY_FACTOR = 0.5        # 1回の減衰でどれだけ学習率を掛け下げるか
WEIGHT_DECAY = 1e-3          # L2正則化の強さ(下のsgd_stepのコメントを参照)
LOG_EVERY = 50               # 何エポックごとにログを出すか
TRACK_EVERY = 20             # 何エポックごとに予測確率の推移を記録するか

OUTPUT_DIR = "output"                              # グラフや学習済みモデルの保存先
MODEL_PATH = os.path.join(OUTPUT_DIR, "model.npz")  # 学習済みパラメータの保存先
                                                     # (predict_cli.py がここから読み込む)


# ============================================================
# 3. コーパスを作る(100文、日本語として自然な語順・共起だけ)
# ============================================================
def build_corpus():
    """
    「主語 + 助詞 + (形容詞) + (天気/雨)」の自然な日本語文だけを集めたコーパスを作る。

    100文を1つずつ手打ちするかわりに、頻度に日本語らしい偏りをつけた組み合わせ生成で
    「自然な文らしい分布」を再現している。
        - 助詞の頻度:「は」(主題化、最頻出)>「も」(並立・追加)>「が」(現象文、ややかたい)
        - 主語ごとの天気の傾向(★ここが一番重要):
            「今日」は晴れやすい(いい天気に偏る)
            「明日」は五分五分(予報の不確実さ)
            「昨日」は悪天候だった、という設定(わるい天気・雨に偏る)
    この「主語ごとの偏り」がないと、天気の予測は直前の助詞・形容詞だけで
    決まってしまい、Attentionが主語を見に行く理由がなくなってしまう。
    主語ごとに傾向を変えることで初めて、「助詞や形容詞が、離れた位置にある
    主語の情報を参照しないと正しく予測できない」という、Attentionが本当に
    必要になる状況を作れる。
    生成ロジックは決定的(乱数を使わない)なので、実行すれば誰の環境でも同じ
    100文が出来上がる。
    """
    subjects = ["今日", "明日", "昨日"]
    particles = ["は", "が", "も"]
    adjectives = ["いい", "わるい"]

    particle_weight = {"は": 3, "が": 1, "も": 2}
    # 主語ごとの「いい/わるい」の出やすさ(今日=晴れ寄り、明日=五分五分、昨日=悪天候寄り)
    subject_adj_bias = {
        "今日": {"いい": 3, "わるい": 1},
        "明日": {"いい": 1, "わるい": 1},
        "昨日": {"いい": 1, "わるい": 3},
    }
    # 主語ごとの「雨」の出やすさ(昨日・明日の方が今日より雨が多い、という設定)
    subject_rain_bias = {"今日": 1, "明日": 2, "昨日": 3}

    sentences = []

    # パターンA: 主語 助詞 形容詞 天気 (例: 「今日 は いい 天気」)
    for s in subjects:
        for p in particles:
            for a in adjectives:
                weight = particle_weight[p] * subject_adj_bias[s][a]
                sentences += [[s, p, a, "天気"]] * weight

    # パターンB: 主語 助詞 雨 (例: 「昨日 が 雨」)
    for s in subjects:
        for p in particles:
            weight = subject_rain_bias[s]
            sentences += [[s, p, "雨"]] * weight

    # パターンC: 主語 助詞 形容詞 (天気を省略した言い方、例: 「今日 は いい」)
    for s in subjects:
        for p in particles:
            for a in adjectives:
                weight = max(subject_adj_bias[s][a] // 2, 1)
                sentences += [[s, p, a]] * weight

    # ちょうど100文に揃えるための代表的な4文(主語ごとの傾向をさらに補強する)
    sentences.append(["今日", "は", "いい", "天気"])
    sentences.append(["明日", "も", "いい", "天気"])
    sentences.append(["昨日", "は", "わるい", "天気"])
    sentences.append(["今日", "が", "雨"])

    return [[token2id[w] for w in sent] for sent in sentences]


def build_training_pairs(corpus):
    """
    各文から「途中まで見て次の単語を当てる」問題(prefix, target)を作る。

    例えば文 [今日, は, いい, 天気] からは
        [今日, は]       → いい
        [今日, は, いい] → 天気
    の2問ができる。先頭2トークン未満の問題(主語1語だけ見て予測、など)は
    情報が少なすぎるので作らない。
    """
    pairs = []
    for sent in corpus:
        for t in range(2, len(sent)):
            prefix = sent[:t]
            target = sent[t]
            pairs.append((prefix, target))
    return pairs


# ============================================================
# 4. 基本演算(softmax, LayerNorm, ReLU)とその逆伝播
# ============================================================
def softmax(x, axis=-1):
    """数値的に安定なsoftmax。最大値を引いてからexpすることでオーバーフローを防ぐ。"""
    x_shift = x - np.max(x, axis=axis, keepdims=True)
    e = np.exp(x_shift)
    return e / np.sum(e, axis=axis, keepdims=True)


def relu(x):
    return np.maximum(0.0, x)


def layer_norm_forward(x, eps=1e-5):
    """
    Layer Normalization:各トークン(行)ごとに、値の平均を0・分散を1に揃える。
    これにより層を重ねても値のスケールが暴れにくくなる。
    学習可能なgamma/beta(スケール・シフト)は省略し、正規化だけを行う最小構成。
    """
    mu = x.mean(axis=-1, keepdims=True)
    var = x.var(axis=-1, keepdims=True)
    std = np.sqrt(var + eps)
    norm = (x - mu) / std
    cache = (x, mu, std, norm)
    return norm, cache


def layer_norm_backward(dnorm, cache):
    """
    LayerNormの逆伝播。
    y = (x - mu) / std という式を x で微分すると、
    「dnorm自身」「dnormの平均」「dnorm・norm の平均 × norm」の3項が出てくる
    (平均・分散の計算にxが2回関わっている=分岐しているため)。
    """
    _, _, std, norm = cache  # xとmuはこの微分式には出てこないので使わない
    mean_dnorm = dnorm.mean(axis=-1, keepdims=True)
    mean_dnorm_norm = (dnorm * norm).mean(axis=-1, keepdims=True)
    dx = (dnorm - mean_dnorm - norm * mean_dnorm_norm) / std
    return dx


# ============================================================
# 5. Positional Encoding(sin/cos、パラメータなしの固定値)
# ============================================================
def positional_encoding(max_len, d_model):
    """
    「同じ単語でも位置が違えば意味が違う」ことをベクトルに埋め込むための位置エンコーディング。
    偶数次元にsin、奇数次元にcosを、周波数を変えながら入れる(Transformer論文の定義そのまま)。
    学習パラメータではないので、逆伝播でここに勾配は流さない(流す必要がない)。
    """
    position = np.arange(max_len)[:, None]                      # (max_len, 1)
    div_term = np.power(10000.0, -(np.arange(0, d_model, 2) / d_model))  # (d_model/2,)
    pe = np.zeros((max_len, d_model))
    pe[:, 0::2] = np.sin(position * div_term)
    pe[:, 1::2] = np.cos(position * div_term)
    return pe

POS_ENC = positional_encoding(MAX_LEN, D_MODEL)


# ============================================================
# 6. モデルパラメータの初期化
# ============================================================
def init_params(seed=RANDOM_SEED):
    """
    全パラメータを小さな乱数で初期化する。0で初期化すると全ニューロンが対称に
    なって学習が進まないので、必ず乱数を使う。スケール0.1は、この規模の
    モデル(次元8〜32)で勾配が発散も消失もしにくい経験的な値。
    """
    rng = np.random.RandomState(seed)

    def M(shape):
        return rng.randn(*shape) * 0.1

    return {
        "Embed": M((VOCAB_SIZE, D_MODEL)),  # トークンID → 意味ベクトル
        "Wq": M((D_MODEL, D_MODEL)),        # Query行列
        "Wk": M((D_MODEL, D_MODEL)),        # Key行列
        "Wv": M((D_MODEL, D_MODEL)),        # Value行列
        "W1": M((D_MODEL, D_FF)),           # FFN 1層目
        "b1": np.zeros(D_FF),
        "W2": M((D_FF, D_MODEL)),           # FFN 2層目
        "b2": np.zeros(D_MODEL),
        "Wout": M((D_MODEL, VOCAB_SIZE)),   # 出力層(文脈ベクトル → 語彙への確率)
        "b_out": np.zeros(VOCAB_SIZE),
    }


# ============================================================
# 7. 順伝播(forward)
# ============================================================
def forward(params, tokens):
    """
    tokens(トークンIDのリスト、長さL)を受け取り、
    「次に来る単語」の確率分布 probs(長さVOCAB_SIZE)を返す。

    処理の流れ:
        Embedding + Positional Encoding
          → Self-Attention(残差接続 + LayerNorm)
          → Feed-Forward Network(残差接続 + LayerNorm)
          → 最後のトークン位置の文脈ベクトルを出力層に通してsoftmax

    「最後のトークン位置」を使うのは、GPTなどのdecoder-onlyモデルと同じで、
    そこまでの文脈をすべて集約した上で「次の単語」を予測するため。
    """
    L = len(tokens)

    # --- Embedding + Positional Encoding ---
    # X[t] = 「トークンtokens[t]の意味ベクトル」+「位置tの位置ベクトル」
    X = params["Embed"][tokens] + POS_ENC[:L]  # (L, D_MODEL)

    # --- Self-Attention ---
    # Query: 「自分は何を探しているか」 / Key: 「自分は何を持っているか」 / Value: 「実際に渡す情報」
    Q = X @ params["Wq"]  # (L, D_MODEL)
    K = X @ params["Wk"]  # (L, D_MODEL)
    V = X @ params["Wv"]  # (L, D_MODEL)

    # QとKの内積でトークン同士の関連度を測り、√d_kで割ってからsoftmaxで正規化する。
    # √d_kで割らないと次元が大きいほど内積が大きくなりすぎて、softmaxが
    # 「ほぼ1つのトークンだけに全集中」という極端な分布になってしまう。
    scores = Q @ K.T / np.sqrt(D_K)  # (L, L) 各行が「このトークンから見た全トークンへの注目度」
    A = softmax(scores, axis=-1)     # Attention重み(各行の合計が1になる)
    attn_out = A @ V                 # 注目度で重み付けしたValueの合計 (L, D_MODEL)

    # 残差接続:Attentionの出力に元の入力Xを足し戻す。
    # こうすることで、Attentionが「何も学んでいない状態」でも入力情報がそのまま
    # 後段に伝わり、深い層でも勾配が消えにくくなる。
    res1 = X + attn_out
    norm1, ln1_cache = layer_norm_forward(res1)

    # --- Feed-Forward Network(各トークン位置に独立に適用する2層MLP)---
    pre_relu = norm1 @ params["W1"] + params["b1"]  # (L, D_FF)
    h = relu(pre_relu)
    ffn_out = h @ params["W2"] + params["b2"]        # (L, D_MODEL)

    res2 = norm1 + ffn_out  # ここでも残差接続
    norm2, ln2_cache = layer_norm_forward(res2)

    # --- 出力層 ---
    # 系列全体を見終わった「最後のトークン位置」の文脈ベクトルだけを使って、
    # 次に来る単語の確率分布を作る。
    logits = norm2[-1] @ params["Wout"] + params["b_out"]  # (VOCAB_SIZE,)
    probs = softmax(logits)

    cache = {
        "tokens": tokens, "X": X, "Q": Q, "K": K, "V": V, "A": A,
        "attn_out": attn_out, "res1": res1, "norm1": norm1, "ln1_cache": ln1_cache,
        "pre_relu": pre_relu, "h": h, "ffn_out": ffn_out, "res2": res2,
        "norm2": norm2, "ln2_cache": ln2_cache, "logits": logits, "probs": probs,
    }
    return probs, cache


# ============================================================
# 8. 逆伝播(backward) ── 出力層 → FFN → Attention → Embedding の順に手計算で遡る
# ============================================================
def backward(params, cache, target_id):
    """
    交差エントロピー損失を、forward()の逆順にチェインルール(合成関数の微分)で
    伝播させ、全パラメータについて「この値をちょっと動かしたら損失がどう変わるか」
    (勾配)を計算する。フレームワークの自動微分を一切使わず、層ごとに手で
    導関数を書き下している。
    """
    tokens = cache["tokens"]
    grads = {k: np.zeros_like(v) for k, v in params.items()}

    # --- 出力層:softmax + 交差エントロピーの合成微分 ---
    # 損失 L = -log(probs[target]) を logits で微分すると、
    # 驚くほどシンプルに "probs - onehot(target)" になる(softmaxの定番の近道)。
    dlogits = cache["probs"].copy()
    dlogits[target_id] -= 1.0

    grads["Wout"] = np.outer(cache["norm2"][-1], dlogits)
    grads["b_out"] = dlogits

    # 出力層は「最後のトークン位置」しか使っていないので、勾配もそこにしか流れない。
    dnorm2 = np.zeros_like(cache["norm2"])
    dnorm2[-1] = params["Wout"] @ dlogits

    # --- LayerNorm2 の逆伝播 ---
    dres2 = layer_norm_backward(dnorm2, cache["ln2_cache"])

    # --- 残差2の逆伝播:res2 = norm1 + ffn_out なので、勾配はそのまま両方に分配される ---
    dnorm1_from_res2 = dres2
    dffn_out = dres2

    # --- FFN の逆伝播 ---
    dh = dffn_out @ params["W2"].T
    grads["W2"] = cache["h"].T @ dffn_out
    grads["b2"] = dffn_out.sum(axis=0)

    dpre_relu = dh * (cache["pre_relu"] > 0)  # ReLUの微分:入力が正なら1、負なら0
    grads["W1"] = cache["norm1"].T @ dpre_relu
    grads["b1"] = dpre_relu.sum(axis=0)
    dnorm1_from_ffn = dpre_relu @ params["W1"].T

    # 残差1の手前で、2つの経路(LayerNorm2の残差経路 と FFN経路)の勾配が合流する
    dnorm1 = dnorm1_from_res2 + dnorm1_from_ffn

    # --- LayerNorm1 の逆伝播 ---
    dres1 = layer_norm_backward(dnorm1, cache["ln1_cache"])

    # --- 残差1の逆伝播:res1 = X + attn_out ---
    dX_from_res1 = dres1
    dattn_out = dres1

    # --- Attention の逆伝播 ---
    dV = cache["A"].T @ dattn_out             # attn_out = A @ V
    dA = dattn_out @ cache["V"].T

    # softmax(行ごと)の逆伝播:
    # softmaxのヤコビアンは対角行列ではないので、"重み × (自分の勾配 - 加重平均)" という形になる。
    sum_term = np.sum(dA * cache["A"], axis=-1, keepdims=True)
    dscores = cache["A"] * (dA - sum_term)

    dQ = dscores @ cache["K"] / np.sqrt(D_K)      # scores = Q @ K.T / sqrt(D_K)
    dK = dscores.T @ cache["Q"] / np.sqrt(D_K)

    grads["Wq"] = cache["X"].T @ dQ
    grads["Wk"] = cache["X"].T @ dK
    grads["Wv"] = cache["X"].T @ dV

    dX_from_attn = dQ @ params["Wq"].T + dK @ params["Wk"].T + dV @ params["Wv"].T

    # 残差経路とAttention経路、両方の勾配がXに合流する
    dX = dX_from_res1 + dX_from_attn

    # --- Embedding の逆伝播 ---
    # Positional Encodingは定数(パラメータではない)なので勾配は流さない。
    # 同じトークンが系列中に複数回出てくる場合に備えて += で加算する。
    for pos, tok in enumerate(tokens):
        grads["Embed"][tok] += dX[pos]

    return grads


def sgd_step(params, grads, lr, weight_decay=0.0):
    """
    SGD(確率的勾配降下法):勾配 × 学習率だけ、パラメータを損失が減る方向に動かす。

    weight_decay(L2正則化)を添えているのには理由がある。LayerNormは
    「入力を丸ごと定数倍しても出力が変わらない」というスケール不変性を持つため、
    損失を変えずにパラメータのノルムだけがどこまでも大きくなっていく方向が
    存在してしまう。実際に weight_decay=0 で長く学習させると、Embeddingの値が
    際限なく発散し、Attentionの出力が入力の違いを無視して「学習で一番よく出た
    単語」ばかりを返す状態に陥る(=どんな入力を入れてもほぼ同じ予測になる)。
    weight_decay はパラメータを少しずつ0に引き戻す力を加えることで、この
    「意味のない方向への発散」にブレーキをかけ、学習を安定させる。
    """
    for k in params:
        params[k] -= lr * (grads[k] + weight_decay * params[k])


# ============================================================
# 9. 学習ループ
# ============================================================
def train(params, pairs, epochs=EPOCHS, lr=LEARNING_RATE, weight_decay=WEIGHT_DECAY,
          lr_decay_every=LR_DECAY_EVERY, lr_decay_factor=LR_DECAY_FACTOR):
    """
    全ての(prefix, target)ペアに対してforward→backward→SGD更新を1件ずつ行うのを
    1エポックとし、それをepochs回繰り返す。あわせて、
      - エポックごとの平均損失(学習曲線用)
      - 「今日 は」の次に来る単語の予測確率の推移(7-1節用)
    を記録して返す。

    学習率は lr_decay_every エポックごとに lr_decay_factor 倍していく
    (いわゆるstep decay)。この規模のデータだと、学習率を最後まで一定に
    したままだと損失がある水準からなかなか下がらず、パラメータが最小値の
    まわりを行ったり来たりして小刻みに揺れ続けてしまう。学習が進むにつれて
    歩幅を小さくしていくことで、その揺れを抑えて滑らかに収束させている。
    """
    loss_history = []
    track_prefix = [token2id["今日"], token2id["は"]]
    prob_track = {"epoch": [], "いい": [], "わるい": [], "天気": [], "雨": []}

    for epoch in range(1, epochs + 1):
        current_lr = lr * (lr_decay_factor ** (epoch // lr_decay_every))
        random.shuffle(pairs)  # 毎エポック順番を変え、順序そのものを覚えてしまうのを防ぐ
        total_loss = 0.0
        for prefix, target in pairs:
            probs, cache = forward(params, prefix)
            total_loss += -np.log(probs[target] + 1e-12)  # 交差エントロピー損失
            grads = backward(params, cache, target)
            sgd_step(params, grads, current_lr, weight_decay)

        avg_loss = total_loss / len(pairs)
        loss_history.append(avg_loss)

        if epoch == 1 or epoch % LOG_EVERY == 0:
            print(f"Epoch {epoch}/{epochs}  Loss: {avg_loss:.4f}  (lr={current_lr:.4f})")

        if epoch == 1 or epoch % TRACK_EVERY == 0:
            probs_now, _ = forward(params, track_prefix)
            prob_track["epoch"].append(epoch)
            for w in ["いい", "わるい", "天気", "雨"]:
                prob_track[w].append(probs_now[token2id[w]])

    return loss_history, prob_track


# ============================================================
# 10. 予測結果を人間が読める形で表示するユーティリティ
# ============================================================
def predict(params, prefix_tokens, top_k=5):
    """
    prefix_tokens(日本語トークンのリスト、例 ["今日", "は"])を入力し、
    次に来る単語の確率分布を計算して、上位top_k件をログに出す。
    学習データにあるかどうかに関わらず、どんなトークン列でも forward できる
    (=コーパスにない語順を入れると何が起きるかも、そのまま試せる)。
    """
    ids = [token2id[t] for t in prefix_tokens]
    probs, _ = forward(params, ids)
    order = np.argsort(-probs)
    ranked = [f"{id2token[i]}({probs[i] * 100:.1f}%)" for i in order[:top_k]]
    print(f"入力: {' '.join(prefix_tokens)} → " + " ".join(ranked) + " ...")
    return probs


def get_attention_matrix(params, tokens):
    """指定したトークン列に対するAttention重み行列(可視化用)を取り出す。"""
    _, cache = forward(params, tokens)
    return cache["A"]


def save_params(params, path=MODEL_PATH):
    """
    学習済みパラメータをnpz形式(NumPy配列をまとめたzip)で保存する。
    params は {"Embed": 配列, "Wq": 配列, ...} という辞書そのものなので、
    そのままキーワード引数として渡せばキー名を保ったまま保存できる。
    これを predict_cli.py が読み込んで、学習し直さずに予測だけを行う。
    """
    os.makedirs(os.path.dirname(path), exist_ok=True)
    np.savez(path, **params)
    print(f"[保存] {path}(学習済みモデル)")


def load_params(path=MODEL_PATH):
    """save_params()で保存したnpzファイルを読み込み、paramsと同じ形の辞書に戻す。"""
    data = np.load(path)
    return {k: data[k] for k in data.files}


# ============================================================
# 11. 可視化(matplotlib)
# ============================================================
def plot_loss_curve(loss_history, path=None):
    """学習曲線:エポックが進むにつれて損失(予測の悪さ)が下がっていく様子。"""
    path = path or os.path.join(OUTPUT_DIR, "loss_curve.png")
    plt.figure(figsize=(6, 4))
    plt.plot(range(1, len(loss_history) + 1), loss_history, color="#1f77b4")
    plt.xlabel("エポック")
    plt.ylabel("損失(クロスエントロピー)")
    plt.title("学習曲線")
    plt.grid(alpha=0.3)
    plt.tight_layout()
    plt.savefig(path, dpi=150)
    plt.close()
    print(f"[保存] {path}")


def plot_attention_heatmap(attn_before, attn_after, tokens, path=None):
    """
    学習前・学習後のAttention重みを並べて表示する。
    行(縦)=Query(注目する側のトークン)、列(横)=Key(注目される側のトークン)。
    学習前は全マスがほぼ均一(どこにも注目していない)だが、学習後は
    「今日」の列(=主語への注目)が全体的に明るくなっているはず。
    このコーパスは主語ごとに天気の傾向を変えてあるので(build_corpus参照)、
    助詞や形容詞が正しく天気を予測するには主語を無視できない。
    そのぶん学習が進むほど主語列への注目が上がる、という設計になっている。

    ただし損失に直接効くのは最後のトークン位置(一番下の行)のQueryだけ。
    途中の位置(この例なら「今日」「は」の行)は最後の位置のKey/Valueとして
    しか使われず、Query自体は間接的にしか学習されないので、一番下の行が
    一番「素直に」解釈できる注目パターンになる。
    """
    path = path or os.path.join(OUTPUT_DIR, "attention_heatmap.png")
    fig, axes = plt.subplots(1, 2, figsize=(11, 4.8))
    im = None
    for ax, mat, title in zip(axes, [attn_before, attn_after], ["学習前(ランダム初期値)", "学習後"]):
        im = ax.imshow(mat, cmap="viridis", vmin=0, vmax=1)
        ax.set_xticks(range(len(tokens)))
        ax.set_xticklabels(tokens)
        ax.set_yticks(range(len(tokens)))
        ax.set_yticklabels(tokens)
        ax.set_xlabel("Key(注目される側)")
        ax.set_ylabel("Query(注目する側)")
        ax.set_title(title)
        for i in range(len(tokens)):
            for j in range(len(tokens)):
                value = mat[i, j]
                ax.text(j, i, f"{value:.2f}", ha="center", va="center",
                        color="white" if value > 0.5 else "black", fontsize=8)
    fig.colorbar(im, ax=axes, shrink=0.8, label="Attention重み")
    fig.suptitle(f"Attention重みヒートマップ(入力: 「{' '.join(tokens)}」)")
    plt.savefig(path, dpi=150)
    plt.close()
    print(f"[保存] {path}")


def plot_prediction_bar(prefix_tokens, probs, path):
    """入力系列に対する、全トークンの予測確率を棒グラフで表示する。"""
    order = np.argsort(-probs)
    labels = [id2token[i] for i in order]
    values = probs[order] * 100
    plt.figure(figsize=(6, 4))
    plt.bar(labels, values, color="#2ca02c")
    plt.ylabel("確率 (%)")
    plt.title(f"入力: 「{' '.join(prefix_tokens)}」の次単語予測")
    plt.xticks(rotation=20)
    plt.tight_layout()
    plt.savefig(path, dpi=150)
    plt.close()
    print(f"[保存] {path}")


def _moving_average(values, window=5):
    """
    表示用の単純な移動平均。1件ずつ更新するSGDはパラメータそのものが
    ステップごとに小刻みに揺れるため、生の推移を毎回そのまま描画すると
    ギザギザして肝心の「全体の傾向」が読み取りにくい。値そのものは
    prob_track にそのまま残しているので、これはあくまで見やすさのための
    平滑化(TensorBoardのsmoothingスライダーと同じ考え方)。
    """
    values = np.array(values, dtype=float)
    if len(values) < window:
        return values
    kernel = np.ones(window) / window
    # "same"だと端が歪むので、端は元の値を使い、中央部分だけ平均化する
    smoothed = np.convolve(values, kernel, mode="valid")
    pad_left = (len(values) - len(smoothed)) // 2
    pad_right = len(values) - len(smoothed) - pad_left
    return np.concatenate([values[:pad_left], smoothed, values[len(values) - pad_right:]])


def plot_prob_evolution(prob_track, path=None):
    """
    「今日 は」の次に来る単語の予測確率が、学習が進むにつれてどう変わるかを追う。
    学習初期はほぼ一様(どれも約10%)→ 学習が進むと「いい」「わるい」に確率が集中していく、
    という変化が見えるはず(表示は読みやすさのため移動平均で軽く平滑化している)。
    """
    path = path or os.path.join(OUTPUT_DIR, "prob_evolution.png")
    plt.figure(figsize=(6.5, 4))
    for w in ["いい", "わるい", "天気", "雨"]:
        values = _moving_average(np.array(prob_track[w]) * 100)
        plt.plot(prob_track["epoch"], values, marker="o", markersize=3, label=w)
    plt.xlabel("エポック")
    plt.ylabel("確率 (%)")
    plt.title("「今日 は」の次単語予測確率の推移(移動平均)")
    plt.legend()
    plt.grid(alpha=0.3)
    plt.tight_layout()
    plt.savefig(path, dpi=150)
    plt.close()
    print(f"[保存] {path}")


# ============================================================
# 12. メイン処理:ここまでの部品を組み立てて、頭から実行する
# ============================================================
if __name__ == "__main__":
    os.makedirs(OUTPUT_DIR, exist_ok=True)
    setup_japanese_font()

    print("=== 1. コーパスを作る ===")
    corpus = build_corpus()
    print(f"コーパス文数: {len(corpus)}")
    pairs = build_training_pairs(corpus)
    print(f"学習サンプル数(文の途中から次の単語を当てる問題の数): {len(pairs)}")

    print("\n=== 2. モデルを初期化する ===")
    params = init_params()
    print(f"パラメータ数の目安: "
          f"{sum(p.size for p in params.values())} 個 "
          f"(Embedding {params['Embed'].size} + Attention "
          f"{params['Wq'].size + params['Wk'].size + params['Wv'].size} + FFN "
          f"{params['W1'].size + params['b1'].size + params['W2'].size + params['b2'].size} + 出力層 "
          f"{params['Wout'].size + params['b_out'].size})")

    # 学習前のAttention重みを、比較用にあらかじめ取っておく。
    # 「今日 は いい」は実際の学習データにも登場する系列(この続きに「天気」が
    # 来ることを学習させている)なので、学習で鍛えられた状態のAttentionを見られる。
    demo_sentence = ["今日", "は", "いい"]
    demo_ids = [token2id[t] for t in demo_sentence]
    attn_before = get_attention_matrix(params, demo_ids)

    print("\n=== 3. 学習前の予測(まだ何も学んでいないのでデタラメなはず) ===")
    predict(params, ["今日", "は"])

    print("\n=== 4. 学習する ===")
    loss_history, prob_track = train(params, pairs)

    attn_after = get_attention_matrix(params, demo_ids)

    print("\n=== 5. 学習後の予測:うまくいく例(学習データにある語順) ===")
    predict(params, ["今日", "は"])
    predict(params, ["明日", "も", "いい"])
    predict(params, ["昨日", "が"])

    print("\n=== 6. 学習後の予測:変な入力(学習データにない語順) ===")
    predict(params, ["雨", "は"])
    predict(params, ["天気", "天気"])
    predict(params, ["いい", "今日"])

    print("\n=== 7. グラフを保存する ===")
    plot_loss_curve(loss_history)
    plot_attention_heatmap(attn_before, attn_after, demo_sentence)
    probs_for_bar = predict(params, ["今日", "は"])
    plot_prediction_bar(["今日", "は"], probs_for_bar, os.path.join(OUTPUT_DIR, "prediction_bar.png"))
    plot_prob_evolution(prob_track)

    print("\n=== 8. 学習済みモデルを保存する ===")
    save_params(params)
    print("predict_cli.py から読み込んで、自分で好きな単語を入力して試せます。")
    print("例: python predict_cli.py 今日 は")

    print(f"\n完了! {OUTPUT_DIR}/ フォルダに画像と学習済みモデルが保存されました。")
"""
predict_cli.py
================

mini_transformer.py で学習したモデルを使って、実際に自分で単語を入力し、
「次にどの単語が来そうか」をその場で試せるCLIツール。

学習そのものはこのファイルではやらない。事前に

    python mini_transformer.py

を実行して、学習済みモデル(output/model.npz)を作っておく必要がある
(このファイルはそこから読み込むだけなので、起動は一瞬)。

使い方:
    # 対話モード(何も引数を渡さずに実行)
    python predict_cli.py

    # ワンショットモード:次の1単語の予測だけ見て終了
    python predict_cli.py 今日 は

    # 自動生成モード:文が完成するまで単語を続けて生成する
    python predict_cli.py --generate 今日 は

対話モードの中では、行の先頭に "g " を付けると自動生成モードになる
(例: "g 今日 は")。"exit" / "quit" / "q" で終了。
"""

import argparse
import os
import sys

import numpy as np

# mini_transformer.py をモジュールとしてインポートする。
# ファイル末尾が `if __name__ == "__main__":` で囲われているので、
# ここでインポートしても学習は始まらず、語彙や forward() などの
# 関数・定数だけが使える状態になる。
import mini_transformer as mt

# 学習コーパスの最長文(主語+助詞+形容詞+天気)と同じ長さで自動生成を打ち切る。
# これより長く生成させても、モデルが経験したことのない長さの文になるだけ。
MAX_GENERATE_LEN = 4


def load_model(path):
    """
    学習済みモデル(npzファイル)を読み込む。まだ学習していない場合は、
    エラーで落とすのではなく「何をすればいいか」を案内して終了する。
    """
    if not os.path.exists(path):
        print(f"学習済みモデルが見つかりません: {path}")
        print("先に次のコマンドでモデルを学習・保存してください。")
        print("    python mini_transformer.py")
        sys.exit(1)
    return mt.load_params(path)


def tokenize(text):
    """
    空白区切りの入力を、mini_transformer.py の語彙のトークンID列に変換する。
    語彙にない単語が混ざっていたら、誤魔化さずにエラーとして知らせる
    (このモデルが知っているのはこの10単語だけ、という制約自体が
    「コーパスの質が全て」という体験の一部でもある)。
    """
    tokens = text.strip().split()
    unknown = [t for t in tokens if t not in mt.token2id]
    if unknown:
        raise ValueError(
            f"語彙にない単語です: {', '.join(unknown)}\n"
            f"使える単語: {' '.join(mt.VOCAB)}"
        )
    if len(tokens) > mt.MAX_LEN:
        raise ValueError(f"入力が長すぎます(最大{mt.MAX_LEN}トークン)。")
    return tokens


def print_distribution(prefix_tokens, probs, bar_width=30):
    """
    全トークンの予測確率を、確率が高い順にテキストの棒グラフで表示する。
    matplotlibを使わずに、ターミナルの中だけで「モデルが何を考えているか」
    をひと目で見せるための簡易可視化。
    """
    order = np.argsort(-probs)
    print(f"\n入力: {' '.join(prefix_tokens)}")
    print("次に来そうな単語:")
    for i in order:
        p = probs[i]
        bar = "█" * round(p * bar_width)
        print(f"  {mt.id2token[i]:<4} {p * 100:5.1f}%  {bar}")


def predict_once(params, prefix_tokens):
    """入力トークン列に対する次単語の確率分布を1回だけ計算して表示する。"""
    ids = [mt.token2id[t] for t in prefix_tokens]
    probs, _ = mt.forward(params, ids)
    print_distribution(prefix_tokens, probs)
    return probs


def generate(params, prefix_tokens, max_len=MAX_GENERATE_LEN):
    """
    貪欲法(毎回いちばん確率の高い単語を選ぶ)で、文の長さが max_len に
    達するまで単語を1つずつ生成し続ける。生成の過程を1ステップずつ表示するので、
    「モデルが1単語ずつ、直前までの文脈だけを見て次を決めている」ことが分かる。
    """
    tokens = list(prefix_tokens)
    print(f"\n入力: {' '.join(tokens)}")
    if len(tokens) >= max_len:
        print(f"(すでに学習データの最長文の長さ{max_len}に達しているので、これ以上は生成しません)")
        return tokens
    while len(tokens) < max_len:
        ids = [mt.token2id[t] for t in tokens]
        probs, _ = mt.forward(params, ids)
        next_id = int(np.argmax(probs))
        next_token = mt.id2token[next_id]
        print(f"  → 「{next_token}」を追加(確信度 {probs[next_id] * 100:.1f}%)")
        tokens.append(next_token)
    print(f"生成結果: {' '.join(tokens)}")
    return tokens


def run_repl(params):
    """引数なしで起動したときの対話モード。ユーザーの入力を待ち続ける。"""
    print("=" * 50)
    print("mini_transformer 対話モード")
    print(f"使える単語: {' '.join(mt.VOCAB)}")
    print("空白区切りで単語を入力してください(例: 今日 は)")
    print("先頭に 'g ' を付けると、文が完成するまで自動生成します(例: g 今日 は)")
    print("終了するには exit / quit / q")
    print("=" * 50)

    while True:
        try:
            text = input("\n> ").strip()
        except (EOFError, KeyboardInterrupt):
            print("\n終了します。")
            break

        if not text:
            continue
        if text.lower() in ("exit", "quit", "q"):
            print("終了します。")
            break

        do_generate = text.startswith("g ")
        if do_generate:
            text = text[2:]

        try:
            tokens = tokenize(text)
        except ValueError as e:
            print(e)
            continue

        if do_generate:
            generate(params, tokens)
        else:
            predict_once(params, tokens)


def main():
    parser = argparse.ArgumentParser(
        description="学習済みmini_transformerモデルで次の単語を予測するCLI。"
                     "引数なしで起動すると対話モードになる。"
    )
    parser.add_argument("tokens", nargs="*",
                         help="入力トークン列(空白区切り、例: 今日 は)")
    parser.add_argument("--generate", action="store_true",
                         help="次の1単語だけでなく、文が完成するまで自動生成する")
    parser.add_argument("--model", default=mt.MODEL_PATH,
                         help=f"モデルファイルのパス(既定: {mt.MODEL_PATH})")
    args = parser.parse_args()

    params = load_model(args.model)

    if not args.tokens:
        run_repl(params)
        return

    try:
        tokens = tokenize(" ".join(args.tokens))
    except ValueError as e:
        print(e)
        sys.exit(1)

    if args.generate:
        generate(params, tokens)
    else:
        predict_once(params, tokens)


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

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?