1. 系列データと RNN / LSTM
📘 前提:このレッスンはRNN/LSTM・Attention の理論を既習として、PyTorchでの実装と「形の追い方」に集中します。 理論があいまいなら、先に E資格対策ページ 「リカレントニューラルネットワーク ― ゲート機構(LSTM/GRU)」 に目を通すとスムーズです。
文章・音声・時系列のような順番のあるデータを扱うのが RNN(再帰型ニューラルネット)。 前の時刻の隠れ状態を次の時刻へ受け渡しながら処理します。 素のRNNは長い系列で勾配が消えやすいため、ゲート機構を持つ LSTM・GRU が使われます。
PyTorch の nn.LSTM は、入力を (batch, seq, feature)(batch_first=True のとき)で受け取ります。
この3次元の形を読めることが第一歩です。
2. LSTM の入出力 shape
nn.LSTM(input_size, hidden_size)。出力は2つ:各時刻の出力 out と、最後の隠れ状態 (h, c) です。
import torch
import torch.nn as nn
torch.manual_seed(0)
rnn = nn.LSTM(input_size=10, hidden_size=20, batch_first=True)
x = torch.randn(4, 7, 10) # (batch=4, seq=7, feature=10)
out, (h, c) = rnn(x)
print("out:", out.shape) # 各時刻の出力 (4, 7, 20)
print("h :", h.shape) # 最後の隠れ状態 (層数=1, batch=4, hidden=20)
print("c :", c.shape) # セル状態 (1, 4, 20)
out: torch.Size([4, 7, 20])
h : torch.Size([1, 4, 20])
c : torch.Size([1, 4, 20])
式だけだとイメージしづらいので、LSTM を時間方向に展開(unroll)した図で out と h の位置を確認します。
out(全時刻ぶん)、最終時刻の h₇・c₇ が h・c として取り出される。入力 (batch=4, seq=7, feature=10) → out (4, 7, 20)、h・c (1, 4, 20)。
💡 out と h の違い(上の図のとおり):
out は全時刻ぶんの隠れ状態 (batch, seq, hidden)=図の h1〜h7 を並べたもの。
h は最後の時刻だけ (層数, batch, hidden)=図の h7。
分類タスクでは系列を1つに集約する必要があるので、最後の時刻 out[:, -1] か h[-1] を全結合に渡すのが定番です。
押さえどころは out の最後の次元の入れ替わりです。最後の次元は、入力の feature(ここでは10)から hidden(ここでは20)に置き換わります——
「10 がそのまま hidden になる」のではなく、別サイズの 20 になる点に注意。batch と seq は変わりません:(4, 7, 10) → (4, 7, 20)。
3. Self-Attention(Transformer の核)
Transformer は RNN のように順番に処理せず、系列の全要素どうしの関連を一度に見ます。その仕組みが Self-Attention。 各要素から Q(クエリ)・K(キー)・V(バリュー)を作り、QとKの内積で「どの要素に注目するか」の重みを決め、Vを加重平均します。
import torch
import torch.nn.functional as F
import math
torch.manual_seed(0)
seq, d = 3, 4 # 系列長3, 次元4
Q = torch.randn(seq, d)
K = torch.randn(seq, d)
V = torch.randn(seq, d)
scores = Q @ K.T / math.sqrt(d) # 関連度 (3, 3)。√dで割る
weights = F.softmax(scores, dim=-1) # 各行を確率に (3, 3)
ctx = weights @ V # Vの加重平均 (3, 4)
print("scores :", scores.shape) # (3, 3)
print("weights:", weights.shape) # (3, 3)
print("出力 :", ctx.shape) # (3, 4)
print("各行の重みの合計:", weights.sum(dim=-1).tolist()) # [1.0, 1.0, 1.0]
scores : torch.Size([3, 3])
weights: torch.Size([3, 3])
出力 : torch.Size([3, 4])
各行の重みの合計: [1.0, 1.0, 1.0]
⚠ なぜ √d で割るのか(つまずきやすい点):
次元 d が大きいと内積 QKᵀ の値が大きくなりがちで、softmax が極端(ほぼ0か1)になり勾配が消えます。
√d で割ってスケールを抑えることで、softmax がなだらかになり学習が安定します。これを スケール化ドット積注意(Scaled Dot-Product Attention)と呼びます。
4. Multi-Head Attention と位置符号
実際の Transformer は、Attention を複数の「ヘッド」に分けて並列に行います(Multi-Head Attention)。
入力の各トークンは長さ embed_dim のベクトルで表され、それを num_heads 個に等分して、各ヘッドが head_dim = embed_dim / num_heads 次元だけを担当します。
各ヘッドが異なる観点の関連を捉え、最後に結合して元の embed_dim に戻します。PyTorch には nn.MultiheadAttention があります。
import torch
import torch.nn as nn
torch.manual_seed(0)
mha = nn.MultiheadAttention(embed_dim=16, num_heads=4, batch_first=True) # 16次元を4ヘッドに分割(各ヘッド head_dim=4)
x = torch.randn(2, 5, 16) # (batch=2, seq=5, embed_dim=16)
# Self-Attentionなので Q=K=V=x を渡す
out, attn = mha(x, x, x)
print("出力 :", out.shape) # (2, 5, 16) 形は保たれる
print("注意の重み :", attn.shape) # (2, 5, 5) 各要素が各要素にどれだけ注目したか
出力 : torch.Size([2, 5, 16])
注意の重み : torch.Size([2, 5, 5])
💡 コードの読み方(Multi-Head Attention):
embed_dim=16… 1トークンを表すベクトルの長さ。入力xは(batch=2, seq=5, embed=16)。num_heads=4… ヘッド数。16 ÷ 4 = 4次元ずつを4つのヘッドが分担(head_dim=4)。out, attn = mha(x, x, x)… Self-Attention なので Q・K・V に同じxを渡す。out… 各トークンを「他のトークンの情報で文脈化」した結果。形は入力と同じ(2, 5, 16)(だから層を何段も積める)。attn… 注意の重み(2, 5, 5)=(batch, seq, seq)。「どのトークンが、どのトークンにどれだけ注目したか」の 5×5 の表(既定では全ヘッドの平均)。
👀 attn の3つ目の次元(=5)はどこから?
注目する側(クエリ)が seq=5 個、注目される側(キー)も同じ系列の seq=5 個なので、組み合わせは 5×5。これにバッチをつけて (batch=2, 5, 5) です。
attn[b, i, j] は「バッチ b で、トークン i がトークン j にどれだけ注目したか」を表し、各行(i を固定)は softmax なので合計1になります。
実装での使い方:ふつうは Transformer ブロックの中で使い、out をそのまま次の層へ流します。1ブロックを自前で組むより、まとめ役の nn.TransformerEncoderLayer を使うのが実務では定番です(§5で使用)。
⚠ embed_dim は num_heads で割り切れること:
各ヘッドは embed_dim を等分した head_dim = embed_dim / num_heads 次元を担当します。割り切れないと等分できずエラーになります
(例:embed_dim=16, num_heads=4 → head_dim=4 はOK/num_heads=3 は 16/3 が割り切れずNG)。
5. 総合演習:Transformer で系列分類
仕上げに、このレッスンの部品——位置符号・Multi-Head Self-Attention(nn.TransformerEncoder の中身)・系列の集約・学習/評価——を1つにまとめた
系列分類パイプラインを読み解きます。各時刻の特徴ベクトルを Transformer で文脈化し、系列全体を平均してクラスを当てます。
🔥 ここが最大の山場! 少し長い、これまでの集大成のコードです。最初は「うっ…」となって当然——でも大丈夫、コメントを1行ずつ追えば必ず読めます。 このコードを最後まで自力で追えたら、Transformer の実装はもう十分に理解できています。胸を張ってOKです! 細かい数式より「どの部品が・どの順で・どんな形のデータを流すか」を掴むのがゴールです。
import torch
import torch.nn as nn
from torch.utils.data import TensorDataset, DataLoader
torch.manual_seed(0)
# ============================================================
# 1) データを用意する
# 長さ12・各時刻8次元の系列を 240本 作り、2クラスに分ける。
# ラベル y =「系列全体の平均値が正なら 1、そうでなければ 0」
# → 系列をうまく集約できれば解ける、素直なタスク
# ============================================================
N, seq, feat = 240, 12, 8
X = torch.randn(N, seq, feat) # (240, 12, 8) = (本数, 系列長, 特徴)
y = (X.mean(dim=(1, 2)) > 0).long() # 各系列の全要素平均>0 → 0/1 ラベル (240,)
train = DataLoader(TensorDataset(X[:200], y[:200]), batch_size=32, shuffle=True) # 学習用200本
test = DataLoader(TensorDataset(X[200:], y[200:]), batch_size=32) # 評価用 40本
# ============================================================
# 2) モデルを定義する
# 各時刻の特徴ベクトルを Transformer で「文脈化」し、
# 系列全体を1つにまとめて 2クラスを予測する
# ============================================================
d_model = 16 # Transformer内部で1トークンを表すベクトル長
class SeqClassifier(nn.Module):
def __init__(self):
super().__init__()
# 入力の特徴8次元 → d_model=16 へ変換(Transformerが扱う次元にそろえる)
self.proj = nn.Linear(feat, d_model)
# --- 位置符号(Positional Encoding)-------------------------------
# Attentionは「順番」を見ないので、"何番目か" の情報を自分で足す必要がある。
# ここでは各位置に専用ベクトルを持たせ、学習で最適化する方式(=学習可能な位置符号)。
# 形 (1, seq, d_model) = (1, 12, 16):
# ・先頭の 1 は「どのバッチでも同じ位置符号を使う」という意味。
# 足し算のとき batch 方向へ自動コピー(ブロードキャスト)される。
# ・nn.Parameter にすると、この位置符号自体も学習対象になる。
self.pos = nn.Parameter(torch.randn(1, seq, d_model))
# --- Transformerエンコーダ本体 -----------------------------------
# encoder_layer 1枚 =「Multi-Head Self-Attention + 全結合(FFN)」のブロック。
# d_model=16 : トークンのベクトル長
# nhead=4 : ヘッド数(16 ÷ 4 = 4 で割り切れること!)
# dim_feedforward=32 : ブロック内の中間全結合の幅
# batch_first=True : 入力を (batch, seq, d_model) の順で渡す
layer = nn.TransformerEncoderLayer(d_model=16, nhead=4,
dim_feedforward=32, batch_first=True)
# そのブロックを 2段 重ねる(num_layers=2)
self.encoder = nn.TransformerEncoder(layer, num_layers=2)
# 集約後のベクトル(16次元) → 2クラスのスコア
self.head = nn.Linear(d_model, 2)
def forward(self, x): # x : (batch, 12, 8)
h = self.proj(x) + self.pos # 16次元へ変換し、位置符号を足す → (batch, 12, 16)
h = self.encoder(h) # 各時刻を "他の時刻の情報" で文脈化 → (batch, 12, 16)
h = h.mean(dim=1) # 系列方向(dim=1)に平均=系列を1ベクトルに集約 → (batch, 16)
return self.head(h) # クラススコア → (batch, 2)
model = SeqClassifier()
loss_fn = nn.CrossEntropyLoss() # 分類の損失(ロジットを渡す。L05参照)
opt = torch.optim.Adam(model.parameters(), lr=1e-3)
# ============================================================
# 3) 評価関数(L08と同じ作法):eval() + no_grad で正解率を測る
# ============================================================
def evaluate(model, loader):
model.eval() # 評価モード(dropout等を評価用に)
correct, total = 0, 0
with torch.no_grad(): # 勾配を計算しない(速い・省メモリ)
for xb, yb in loader:
correct += (model(xb).argmax(1) == yb).sum().item() # 予測(argmax)が正解と一致した数を加算
total += yb.size(0)
return correct / total # 正解数 ÷ 全体
# ============================================================
# 4) 学習ループ(L06):train()で1エポック学習 → evaluate()で測る
# ============================================================
for epoch in range(5):
model.train() # 学習モード
for xb, yb in train: # ミニバッチごとに
opt.zero_grad() # ① 前回の勾配をリセット
loss = loss_fn(model(xb), yb) # ② 順伝播 → 損失
loss.backward() # ③ 逆伝播(勾配を計算)
opt.step() # ④ パラメータ更新
print(f"epoch {epoch} test_acc {evaluate(model, test):.3f}") # 1エポックごとにテスト精度
epoch 0 test_acc 0.600
epoch 1 test_acc 0.625
epoch 2 test_acc 0.625
epoch 3 test_acc 0.675
epoch 4 test_acc 0.800
💡 ここを変えて試してみよう(まず出力を予想 → 手元で確かめる):
nhead=4を2や8に変えると?(d_model=16で割り切れる数にすること)num_layersを増やすと精度・学習時間はどうなる?- 位置符号
self.posを足すのをやめると、順番が関わるタスクでどう変わる? - Transformer の代わりに
nn.LSTM+out[:, -1]で分類器を作ると?
※ PyTorchはこのページでは動かせないので、まず頭の中で予想してから手元で実際に動かすのがおすすめ(合成タスクなので精度は参考値)。
6. 練習問題
Attention スコアの shape は?
Q と K がどちらも形 (seq=5, d=8) のとき、Q @ K.T の shape はどれですか。
- A. (8, 8)
- B. (5, 5)
- C. (5, 8)
答えと解説を見る
正解:B. (5, 5)
(5,8) @ (8,5) = (5,5)。系列の各要素どうしの関連度なので、行・列とも系列長 seq になります。
なぜ √d で割る?
Self-Attention で QKᵀ を √d で割る理由はどれですか。
- A. 計算を速くするため
- B. 出力の形を変えるため
- C. 内積が大きくなりすぎて softmax の勾配が消えるのを防ぐため
答えと解説を見る
正解:C
次元が大きいと内積が大きくなり、softmax が極端になって勾配が小さくなります。√d で割ってスケールを抑え、学習を安定させます。
LSTM の出力 shape は?
nn.LSTM(input_size=8, hidden_size=32, batch_first=True) に (16, 10, 8) を通したときの out の shape はどれですか。
- A. (16, 10, 32)
- B. (16, 10, 8)
- C. (16, 32)
答えと解説を見る
正解:A. (16, 10, 32)
batch=16・seq=10 はそのまま、feature が 8 → hidden=32 に変わります。
embed_dim と num_heads の関係は?
nn.MultiheadAttention(embed_dim, num_heads) で成り立つべき条件はどれですか。
- A. num_heads が embed_dim より大きい
- B. embed_dim が num_heads で割り切れる
- C. 両者が等しい
答えと解説を見る
正解:B
埋め込み次元をヘッド数で等分するため、embed_dim は num_heads で割り切れる必要があります(例:16÷4=4)。
割り切れないとエラーになります。
LSTM の out の shape は?
次のコードの out の shape はどれですか。
rnn = nn.LSTM(input_size=12, hidden_size=24, batch_first=True)
out, (h, c) = rnn(torch.randn(8, 15, 12))
- A. (8, 15, 12)
- B. (8, 24)
- C. (8, 15, 24)
答えと解説を見る
正解:C. (8, 15, 24)
batch=8・seq=15 はそのまま、feature が 12 → hidden=24 に変わります。out は全時刻ぶんなので3次元です。
注意の重み attn の shape は?
次のコードの attn の shape はどれですか。
mha = nn.MultiheadAttention(embed_dim=32, num_heads=8, batch_first=True)
x = torch.randn(4, 6, 32)
out, attn = mha(x, x, x)
- A. (4, 6, 6)
- B. (4, 6, 32)
- C. (6, 6)
答えと解説を見る
正解:A. (4, 6, 6)
注意の重みは「各トークンが各トークンにどれだけ注目したか」なので (batch, seq, seq) = (4, 6, 6)。
out のほうは入力と同じ (4, 6, 32) です(32÷8=4 でヘッドに分割)。
7. まとめ
このレッスンのポイント
- RNN/LSTM は順番のあるデータを隠れ状態を渡しながら処理。入力は
(batch, seq, feature) - LSTM の
outは全時刻(batch, seq, hidden)、hは最後の隠れ状態(分類はout[:, -1]かh[-1]) - Self-Attention は softmax(QKᵀ/√d)·V。スコアは
(seq, seq) √dで割るのは softmax を安定させ勾配消失を防ぐため- Multi-Head は Attention を複数ヘッドで並列実行。
head_dim = embed_dim / num_headsなので embed_dim は num_heads で割り切れる - 位置符号(Positional Encoding)で語順を入力に足して伝える
- 系列分類は 位置符号 →
nn.TransformerEncoder→ 系列を集約(平均など)→ 全結合の流れ
ここまでで深層学習の主要アーキ(全結合・CNN・系列/Transformer)の実装を読めるようになりました。 次の Unit 4 では、シラバス範囲内の機械学習(パターン認識・評価指標)を scikit-learn で実際に動かします。
完了するとコース一覧に進捗が記録されます