6
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Elixir と Livebook で学ぶ LLM / Transformer 入門 〜 トークン化からミニGPTの自作学習まで

6
Last updated at Posted at 2026-07-30

はじめに

LLM(大規模言語モデル)や Transformer の仕組みは、記事や動画で断片的に説明されていることが多く、「アテンションだけ分かった」「学習の流れだけ分かった」という状態で終わってしまいがちです

この記事では、ElixirLivebook を使って、LLM の中心にある1つの問いを少しずつ広げながら理解していく教材シリーズを紹介します

モデルは、何を手がかりに次のトークンを予測するのか?

対象読者は次のような方です

  • Elixir はある程度書けるが、LLM や Transformer の内部構造は詳しく知らない
  • 数式だけの説明より、実際にコードを動かして表やグラフで確認したい

ノートブックは GitHub で公開しています

事前準備

Livebook のインストール方法は、公式サイトを参照してください
デスクトップアプリ・Docker・Fly.io など複数の方法があります

以下のバッジから、各ノートブックを直接 Livebook で開くこともできます(Livebook が起動していれば、そのままクリックして開けます)

シリーズの入口はこちらです

Run in Livebook

シリーズの全体構成

このシリーズは本編6章と発展編1章の、合計7つのノートブックで構成しています

ノートブック 内容
1 01_llm_overview_tokens_embeddings.livemd LLMの全体像・トークン化・one-hotの限界・埋め込み
2 02_tiny_bigram_language_model_training.livemd 極小の言語モデル(bigram)を実際に学習する
3 03_attention_causal_mask_position.livemd アテンション・因果マスク・位置エンコーディング
4 04_mini_gpt_from_scratch.livemd ミニGPTを組み立てて、埋め込みからアテンションまで全部学習する
5 05_decoder_generation_with_bumblebee.livemd 事前学習済み GPT-2 で次トークン予測と生成を体感する
6 06_alignment_data_and_preferences.livemd SFT / DPO によるアラインメントの直感を学ぶ
発展編 07_advanced_encoder_decoder_transformer.livemd エンコーダー・デコーダー構成とクロスアテンション

第1章: LLMの全体像・トークン化・埋め込み

最初の章では、細かい部品に入る前に「GPT系モデルは結局何をしているのか」を確認します

GPT系モデルは、ここまでのトークンを読み、次の1トークンを予測する ことを繰り返しているだけです
この章では、図の ab、つまり「文章をトークンに分けて、ベクトルにする」ところまでを扱います

トークン化と one-hot の限界

まずは空白区切りの簡単な文をトークンに分けます

sentence = "ねこ は ひるね が すき"
tokens = String.split(sentence, " ")

トークンには整数の ID を振りますが、ID をそのまま計算に使うのは無理があります

そこで最初に思いつくのが one-hot 表現です
ただし、one-hot には大きな弱点があります
どの2語を比べても「まったく似ていない」ことになるのです

語彙の全トークン同士で内積(ベクトルの近さを測る計算)を取ってみると、それがよく分かります

# 語彙の全トークン分のone-hotを作り、全組み合わせの内積を求める
vocab_one_hot =
  Nx.equal(
    Nx.new_axis(Nx.iota({vocab_size}), 1),
    Nx.iota({vocab_size})
  )
  |> Nx.as_type(:f32)

one_hot_similarity = Nx.dot(vocab_one_hot, [1], vocab_one_hot, [1])

one_hot_similarity_rows =
  for {token_a, i} <- Enum.with_index(vocab),
      {token_b, j} <- Enum.with_index(vocab) do
    %{
      token_a: token_a,
      token_b: token_b,
      dot: Nx.to_number(one_hot_similarity[[i, j]])
    }
  end

LLMScratch.Visuals.heatmap(
  one_hot_similarity_rows,
  "one-hot 表現の内積(全組み合わせ)",
  :token_b,
  :token_a,
  :dot
)

実行結果

自分自身との内積は 1.0、それ以外はすべて 0.0 になります

つまり one-hot の世界では、「ねこ」と「ひるね」の関係も、「ねこ」と「が」の関係も、まったく同じ「無関係」として扱われてしまいます

visualization.png

埋め込みなら「近さ」を表現できる

そこで LLM では、one-hot の代わりに密な実数ベクトルである埋め込み(embedding)を使います
この章では説明用に、人手で2次元の埋め込みを割り当てます

embedding_map = %{
  "ねこ" => [0.90, 0.15],
  "は" => [0.10, 0.05],
  "ひるね" => [0.95, 0.85],
  "が" => [0.12, 0.10],
  "すき" => [0.88, 0.70]
}

内容語(ねこ・ひるね・すき)を右上寄りに、助詞(は・が)を左下寄りに置いてあります
同じように内積を計算すると、今度は結果が変わります

embedding_similarity = Nx.dot(embedding_tensor, [1], embedding_tensor, [1])

実行結果(「すき」から見た各トークンとの内積)

相手のトークン 内積
ねこ 0.897
0.123
ひるね 1.431
0.176
すき 1.264

one-hot ではどの組み合わせも 0 でしたが、埋め込みでは すきひるね の内積が大きく、すき の内積は小さくなります
ベクトルの向きが近いほど内積が大きい、というこの感覚が、次章以降のアテンションの土台になります

visualization (1).png

第2章: 極小の言語モデル(bigram)を学習する

第1章で「文章を数値に直す」ところまで進んだので、第2章では実際に学習をしてみます
ただし、いきなり Transformer 全体は作らず、1個前のトークンだけを見る bigram 言語モデル を使います

学習用のコーパスには、この後の章でも使い回す6つの短い文を用意しました

corpus_sentences = [
  "ねこ は ひるね が すき",
  "ねこ は さかな が すき",
  "ねこ は まどべ で ひるね",
  "いぬ は さんぽ が すき",
  "いぬ は にわ を かける",
  "とり は そら を とぶ"
]

現在のトークン -> 次のトークン のペアに分解し、Nx.Defn.grad で自動微分しながら重み行列を更新していきます

defn loss(weights, input_ids, target_one_hot) do
  logits = Nx.take(weights, input_ids)
  probs = row_softmax(logits)

  target_one_hot
  |> Nx.multiply(Nx.log(Nx.add(probs, 1.0e-9)))
  |> Nx.sum(axes: [1])
  |> Nx.negate()
  |> Nx.mean()
end

defn update(weights, input_ids, target_one_hot, learning_rate) do
  grads = grad(weights, &loss(&1, input_ids, target_one_hot))
  weights - grads * learning_rate
end

visualization (2).png

学習が進むにつれて損失が下がり、学習前はランダムだった予測が、コーパスの傾向に沿ったものへ変わっていきます

bigram の限界(この章でいちばん大事な観察)

この章の主役は、次の表です

focus_source = "は"

focus_count_rows =
  count_rows
  |> Enum.filter(&(&1.source == focus_source))
  |> Enum.reject(&(&1.score == 0))
  |> Enum.sort_by(& &1.score, :desc)

Kino.DataTable.new(focus_count_rows, keys: [:source, :target, :score])

スクリーンショット 2026-07-30 100717.png

の次には複数の語が現れますが、bigram モデルは「 の前に ねこ があったのか いぬ があったのか」を見られません

そのため、

  • ねこ は の続き
  • いぬ は の続き

に対して、まったく同じ確率分布しか出せません

この「は問題」が解けるかどうかが、次章以降のテーマになります

第3章: アテンション・因果マスク・位置エンコーディング

bigram の限界を解決するために、Transformer の中心となる3つの仕組みを実際に計算しながら学びます

  • アテンション: 前にある全トークンを「重み付き」で参照する
  • 因果マスク: 未来のトークンを見ないよう制限する
  • 位置エンコーディング: 語順の情報を補う

まずは「均等に混ぜる」を試して、限界を見る

「前のトークンを全部使えばいい」という発想の、いちばん単純な形は均等な重みで平均することです

uniform_weight = 1.0 / length(tokens)
uniform_weights = Nx.broadcast(uniform_weight, {length(tokens)})
uniform_mixture = Nx.dot(uniform_weights, [0], embedding_tensor, [0])

実行結果: [0.59, 0.37]

文全体の情報は手に入りますが、ひるね のような大事な語も のような機能語も同じ重み(0.2)で混ざってしまいます
必要なのは、今の位置が何を知りたいかに応じて、参照先ごとに重みを変える仕組みです

アテンション: Query / Key / Value

scaled_dot_attention = fn query, keys, values ->
  d_model = keys |> Nx.shape() |> elem(1)

  scores = Nx.divide(Nx.dot(keys, [1], query, [0]), :math.sqrt(d_model))
  weights = softmax_1d.(scores)

  output = Nx.dot(weights, [0], values, [0])
  {output, weights}
end

すき を Query として全トークンを参照させると、均等な 0.2 とは違い、意味的に近い ひるねねこ への重みが大きくなります

visualization (3).png

名前 図書館でのたとえ アテンションでの役割
Query 今探しているテーマ 現在位置が「どんな情報がほしいか」を表す
Key 各資料の見出し Query と比べ、参照先としてどれくらい合うかを測る
Value 資料の中身 Key が合った位置から、実際に持ち帰って混ぜる情報

因果マスク: 未来を見せない

GPT の学習では、完成した文章を 現在まで -> 次の正解 にずらして使います
もし右側の答えまで見えていたら、モデルは答えを写すだけで損失を下げられてしまいます

mask_tensor =
  for i <- 0..(length(decoder_tokens) - 1) do
    for j <- 0..(length(decoder_tokens) - 1) do
      j > i
    end
  end
  |> Nx.tensor(type: {:u, 8})

masked_scores =
  Nx.select(mask_tensor, Nx.broadcast(-1.0e9, Nx.shape(score_matrix)), score_matrix)

visualization (5).png

ヒートマップの右上(未来への参照)が 0 になっていれば、各トークンが自分より右側を見ていないことが確認できます

位置エンコーディング: 語順を補う

アテンションの計算だけでは、どのトークンが何番目にあったか を直接は持ちません
そこで sin / cos を使った位置エンコーディングを、埋め込みに足し込みます

positional_encoding = fn max_position, d_model ->
  for pos <- 0..(max_position - 1) do
    for dim <- 0..(d_model - 1) do
      angle = pos / :math.pow(10_000, (2 * div(dim, 2)) / d_model)
      if rem(dim, 2) == 0, do: :math.sin(angle), else: :math.cos(angle)
    end
  end
  |> Nx.tensor(type: {:f, 32})
end

visualization (6).png

これで、Transformer を組み立てるための部品がそろいました

第4章: ミニGPTを組み立てて、端から端まで学習する

この章がシリーズの山場です
第1〜3章の部品を全部つなげて、デコーダーのみのミニGPTを実装し、第2章とまったく同じコーパスで、埋め込みからアテンションまで全パラメータを学習します

第3章のアテンションに、この章で初めて次の4つが加わります

部品 役割のイメージ
マルチヘッドアテンション アテンションを複数の「見方」に分けて並列に計算し、最後に結合する
フィードフォワード層 アテンションで集めた情報を、各位置ごとに個別に加工する
残差接続 層の入力を出力へ足し戻し、元の情報が消えないようにする
レイヤー正規化 各位置のベクトルのスケールを整え、学習を安定させる

マルチヘッド自己アテンション

def self_attention(x, params, num_heads, mask) do
  q = x |> linear(params.wq, params.bq) |> split_heads(num_heads)
  k = x |> linear(params.wk, params.bk) |> split_heads(num_heads)
  v = x |> linear(params.wv, params.bv) |> split_heads(num_heads)

  head_dim = q |> Nx.shape() |> elem(3)

  scores =
    Nx.dot(q, [3], [0, 1], k, [3], [0, 1])
    |> Nx.divide(:math.sqrt(head_dim))

  masked_scores =
    Nx.select(
      Nx.broadcast(mask, Nx.shape(scores)),
      Nx.broadcast(-1.0e9, Nx.shape(scores)),
      scores
    )

  weights = softmax(masked_scores)

  output =
    Nx.dot(weights, [3], [0, 1], v, [2], [0, 1])
    |> combine_heads()
    |> linear(params.wo, params.bo)

  {output, weights}
end

GPTブロックは、このアテンションとフィードフォワード層を、残差接続とレイヤー正規化で包んだものです

def gpt_block(x, params, num_heads, mask) do
  {attention_output, attention_weights} =
    self_attention(x, params.attention, num_heads, mask)

  x1 = layer_norm(Nx.add(x, attention_output), params.ln1.gamma, params.ln1.beta)
  ff_output = feed_forward(x1, params.feed_forward)
  x2 = layer_norm(Nx.add(x1, ff_output), params.ln2.gamma, params.ln2.beta)

  {x2, attention_weights}
end

パラメータは木構造、学習の流れは第2章と同じ

第2章では重みが1枚の行列でしたが、ミニGPTのパラメータは map と tuple を入れ子にした木になります
Nx.Defn.value_and_grad は、その木と同じ形をした勾配をまとめて返してくれるので、木の葉(tensor)1つ1つに勾配降下を適用する関数を用意します

defmodule LLMScratch.SGD do
  def apply_gradients(%Nx.Tensor{} = param, %Nx.Tensor{} = gradient, learning_rate) do
    Nx.subtract(param, Nx.multiply(gradient, learning_rate))
  end

  def apply_gradients(params, gradients, learning_rate) when is_map(params) do
    Map.new(params, fn {key, value} ->
      {key, apply_gradients(value, Map.fetch!(gradients, key), learning_rate)}
    end)
  end

  def apply_gradients(params, gradients, learning_rate) when is_tuple(params) do
    params
    |> Tuple.to_list()
    |> Enum.zip(Tuple.to_list(gradients))
    |> Enum.map(fn {param, gradient} -> apply_gradients(param, gradient, learning_rate) end)
    |> List.to_tuple()
  end
end

学習ループ自体は、第2章の weights - grads * learning_rate を全パラメータに広げただけです

value_and_grad =
  Nx.Defn.value_and_grad(fn {params, ids, targets} ->
    LLMScratch.MiniGPT.loss(params, ids, targets, config.num_heads)
  end)

{trained_params, loss_rows} =
  Enum.reduce(0..epochs, {initial_params, []}, fn epoch, {params, rows} ->
    {loss, {gradients, _ids_grad, _targets_grad}} =
      value_and_grad.({params, input_ids, target_one_hot})

    next_params = LLMScratch.SGD.apply_gradients(params, gradients, learning_rate)
    {next_params, [%{epoch: epoch, loss: Nx.to_number(loss)} | rows]}
  end)

d_model: 16num_heads: 2d_ff: 32、ブロック数2 という設定で、パラメータ数は約5,000個です

GPT-2(第5章で使用)が約1.2億個、近年の大規模LLMが1兆個規模なのと比べると、けた違いに小さいモデルですが、構造はそのまま同じです

bigram が解けなかった問題が解けた瞬間

実際に学習を実行した結果がこちらです(EXLA バックエンドで学習時間は約6.5秒でした)

実行結果(損失の推移)

visualization (7).png

損失は 0 まで下がらず、0.30 付近で止まります
これは失敗ではありません
このコーパスには <BOS> の次や ねこ は の次など、文脈を完全に読んでも一意に決まらない箇所が残っているためで、損失の下限はモデルの能力ではなくデータの曖昧さで決まることがここで確認できます

そして、第2章で「は問題」として残した3つのプレフィックスに、学習後のモデルがどう答えるかがこちらです

実行結果(学習後の次トークン予測、上位3件)

<BOS> ねこ は: [
  {"ひるね", 0.380}, {"まどべ", 0.308}, {"さかな", 0.307}
]
<BOS> いぬ は: [
  {"さんぽ", 0.498}, {"にわ", 0.496}, {"そら", 0.002}
]
<BOS> とり は: [
  {"そら", 0.991}, {"にわ", 0.002}, {"さかな", 0.002}
]

3行とも直前のトークンは同じ ですが、bigram では出せなかったそれぞれ違う予測が出ています
しかも、確率の値は、コーパス内の出現割合(ねこ は の続きは3候補で約1/3ずつ、いぬ は の続きは2候補で約1/2ずつ、とり は の続きは そら のみ)とほぼ一致しています
因果マスク付き自己アテンションが、2つ前の主語トークンを参照できているということです

visualization (8).png
visualization (9).png

最後に、学習済みモデルで貪欲法(greedy)による文章生成も試します

generate = fn params, max_steps ->
  Enum.reduce_while(1..max_steps, ["<BOS>"], fn _, acc ->
    [{next, _probability} | _] = next_token_probs.(params, acc)

    if next == "<EOS>" or length(acc) >= max_steps do
      {:halt, acc ++ [next]}
    else
      {:cont, acc ++ [next]}
    end
  end)
end

実行結果: ["<BOS>", "ねこ", "は", "ひるね", "が", "すき", "<EOS>"]

コーパスに含まれる自然な文がそのまま生成されました

第5章: 事前学習済み GPT-2 で生成を体感する

第4章で作ったミニGPTは、語彙18個・パラメータ約5,000個のおもちゃのようなモデルでした
第5章では、Bumblebee を使って本物の GPT-2(パラメータ約1.2億個)を動かし、同じ仕組みが実物になるとどう見えるかを確認します

repo = {:hf, "gpt2", cache_dir: cache_dir}

{:ok, gpt2} = Bumblebee.load_model(repo)
{:ok, tokenizer} = Bumblebee.load_tokenizer(repo)
{:ok, generation_config} = Bumblebee.load_generation_config(repo)

サブワード分割を目で見る

第1章では空白区切りの簡易トークナイザーを使いましたが、実物の GPT-2 は単語よりも細かい「サブワード」という単位を使います

tokenized = Bumblebee.apply_tokenizer(tokenizer, prompt)
token_ids = tokenized["input_ids"][[0]] |> Nx.to_flat_list()

# 各IDを1トークンずつ復号し、元の文字列のどの部品かを確認する
token_rows =
  token_ids
  |> Enum.with_index()
  |> Enum.map(fn {token_id, position} ->
    %{
      position: position,
      token_id: token_id,
      piece: Bumblebee.Tokenizer.decode(tokenizer, [token_id])
    }
  end)

Kino.DataTable.new(
  token_rows,
  keys: [:position, :token_id, :piece]
)

スクリーンショット 2026-07-30 103244.png

Tokenization のような単語が Tokenization のように分割される様子が確認できます

greedy と sampling の違い

greedy_config =
  Bumblebee.configure(generation_config,
    max_new_tokens: 40,
    strategy: %{type: :greedy_search}
  )

sampling_config =
  Bumblebee.configure(generation_config,
    max_new_tokens: 40,
    temperature: 1.1,
    strategy: %{type: :multinomial_sampling, top_k: 40, top_p: 0.9}
  )

greedy の場合

試行 1
The first thing to note is that the API is not a singleton. It is a collection of objects. Each object is a collection of objects.

The first thing to note is

---

試行 2
The first thing to note is that the API is not a singleton. It is a collection of objects. Each object is a collection of objects.

The first thing to note is

---

試行 3
The first thing to note is that the API is not a singleton. It is a collection of objects. Each object is a collection of objects.

The first thing to note is

sampling の場合

試行 1
In the past, it has been suggested that it was just a minor nuisance or an obvious mistake and is now considered a bug in Ethereum and potentially even a fatal error. The only other reason Ethereum is

---

試行 2
For example, you may be able to figure out why the user has been turned off for a short period of time in a given set of contexts. In the case of a single example, the fact

---

試行 3
It may cause the system to shut down immediately after a critical operation to prevent that application from having enough data to do its job.

With all this thought, let's try something new. This

同じ入力でも、greedy は毎回同じ結果になり、sampling は実行のたびに違う続きが選ばれます
「創造性」という特別な能力が足されたわけではなく、次トークン候補から常に1位を取るか、上位候補を抽選するか という小さな違いが、生成全体の分岐につながっているだけだと分かります

第6章: SFT / DPO によるアラインメント

事前学習だけを終えたモデルは、文章の続きは予測できても、必ずしも人の指示に分かりやすく答えるとは限りません
第6章では、その調整方法である SFT(教師あり微調整)と DPO(選好最適化)を、小さなデータで体験します

SFT では、指示 -> 望ましい応答 のペアを正解として学習します

defn sft_loss(policy_logits, prompt_ids, chosen_one_hot) do
  log_probs =
    policy_logits
    |> Nx.take(prompt_ids)
    |> log_softmax()

  log_probs
  |> Nx.multiply(chosen_one_hot)
  |> Nx.sum(axes: [1])
  |> Nx.negate()
  |> Nx.mean()
end

DPO では、同じ指示に対する chosen(望ましい応答)と rejected(望ましくない応答)を比べ、基準モデルから離れすぎないようにしながら、chosen を相対的に選びやすくします

defn dpo_loss(policy_logits, reference_logits, prompt_ids, chosen_one_hot, rejected_one_hot, beta) do
  policy_log_probs = policy_logits |> Nx.take(prompt_ids) |> log_softmax()
  reference_log_probs = reference_logits |> Nx.take(prompt_ids) |> log_softmax()

  policy_chosen = Nx.sum(policy_log_probs * chosen_one_hot, axes: [1])
  policy_rejected = Nx.sum(policy_log_probs * rejected_one_hot, axes: [1])
  reference_chosen = Nx.sum(reference_log_probs * chosen_one_hot, axes: [1])
  reference_rejected = Nx.sum(reference_log_probs * rejected_one_hot, axes: [1])

  relative_margin =
    Nx.subtract(policy_chosen, policy_rejected) -
      Nx.subtract(reference_chosen, reference_rejected)

  preferred_probability =
    Nx.divide(1.0, Nx.add(1.0, Nx.exp(Nx.negate(beta * relative_margin))))

  preferred_probability |> Nx.add(1.0e-9) |> Nx.log() |> Nx.negate() |> Nx.mean()
end

visualization (10).png

学習前は chosen と rejected が同確率、SFT 後は chosen の確率が上がり、DPO 後は chosen と rejected の対数確率マージンがさらに広がる、という3段階の変化を確認できます

発展編: エンコーダー・デコーダー構成とクロスアテンション

本編はここまでの GPT 系(デコーダーのみ)で完結していますが、Transformer が最初に発表された論文は機械翻訳のための、エンコーダーとデコーダーを組み合わせた構成でした
発展編では、その元祖の構成を組み立てます

デコーダーブロックには、第4章のミニGPTにはなかったクロスアテンションが追加されています
デコーダー側をQuery、エンコーダーの記憶をKey/Valueとして参照する仕組みです

def decoder_block(x, memory, params, num_heads, causal_mask) do
  {self_attn_out, self_weights} =
    multi_head_attention(x, x, x, params.self_attention, num_heads, causal_mask)

  x1 = residual_layer_norm(x, self_attn_out, params.ln1)

  {cross_attn_out, cross_weights} =
    multi_head_attention(x1, memory, memory, params.cross_attention, num_heads)

  x2 = residual_layer_norm(x1, cross_attn_out, params.ln2)
  ff_out = feed_forward(x2, params.feed_forward)
  x3 = residual_layer_norm(x2, ff_out, params.ln3)

  {x3, %{self_weights: self_weights, cross_weights: cross_weights}}
end

英日翻訳の小さな例(i really like green tea -> わたし は 緑茶 が 好き など4例)を使い、出力層を学習すると、デコーダー入力が同じ <BOS> わたし は でも、クロスアテンションが参照するエンコーダー側の記憶(green tea / cats / coffee / books)によって、続く目的語を選び分けられるようになります

visualization (11).png
visualization (12).png

まとめ

  • LLM の中心は「ここまでのトークンから次の1トークンを予測する」というシンプルな問い
  • one-hot では語同士の近さを表せず、埋め込みなら内積で近さを測れる
  • bigram モデルには「直前の1語しか見られない」という原理的な限界がある
  • アテンション・因果マスク・位置エンコーディングを組み合わせると、その限界を超えられる
  • ミニGPTを自分の手で端から端まで学習すると、bigram には出せなかった予測が実際に出せる
  • 実物の GPT-2 も、この教材で作ったミニGPTと同じ構造をスケールアップしたもの
  • SFT / DPO は、事前学習済みモデルを人の指示や好みに合わせる調整の入り口
  • エンコーダー・デコーダー構成とクロスアテンションを知ると、翻訳のような別系列間のタスクにも応用が効く

気になった章から、ぜひ手元の Livebook で実行してみてください

6
3
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
6
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?