Machine Learning Academy · レッスン

Sequence-to-One:LSTMによる感情分析

レビューをトークン化し、nn.Embeddingで単語を埋め込み、LSTMに通して、最後の隠れ状態を分類するエンドツーエンドの感情分類器を構築します。

レッスン 4/413 ステップ

「Sequence-to-One:LSTMによる感情分析」はCoddyKit上の無料Machine Learning Academyレッスンです。 これはレッスン4/4です。 下記で完全なレッスンを無料で読むことができます。その後、ブラウザ内の組み込みコードエディタと24時間対応のAIチューターでハンズオン演習できます。 これはMachine Learning Academy学習パスの一部であり、ウェブとCoddyKitアプリ全体で進捗が同期されます。 Machine Learning Academyコースには全4レッスンが含まれています。

このレッスンの一部はまだ翻訳されておらず、英語で表示されています。

Sequence-to-One Classification Explained

In sequence-to-one tasks, the model reads an entire sequence and produces a single output label. Sentiment analysis is the classic example: given a full movie review ('I loved every minute of this film'), predict a single label — positive or negative.

This contrasts with sequence-to-sequence (translation, where every input token produces an output token) and one-to-sequence (image captioning). In sequence-to-one, we discard intermediate LSTM outputs and use only the final hidden state h_T, which theoretically encodes the meaning of the entire input sequence.

Dataset: IMDB Movie Reviews

The IMDB dataset contains 50,000 movie reviews (25,000 train, 25,000 test) labelled as positive or negative. Each review is a variable-length string of text. Before feeding it to an LSTM, we need three preprocessing steps: tokenisation (split text into words), vocabulary building (map words to integer indices), and padding (make all sequences the same length).

We also need to handle reviews that are very long — LSTM training slows quadratically with sequence length, so truncating to 256 or 512 tokens is common practice without significantly hurting accuracy.

from torchtext.datasets import IMDB
from torchtext.data.utils import get_tokenizer
from torchtext.vocab import build_vocab_from_iterator

tokenizer = get_tokenizer('basic_english')

def yield_tokens(data_iter):
    for _, text in data_iter:
        yield tokenizer(text)

train_iter = IMDB(split='train')
vocab = build_vocab_from_iterator(yield_tokens(train_iter),
                                   specials=['<unk>', '<pad>'])
vocab.set_default_index(vocab['<unk>'])
print('Vocabulary size:', len(vocab))

Tokenising and Padding Sequences

Padding is necessary because PyTorch DataLoaders require tensors of the same shape within a batch. We pad shorter sequences with a special <pad> token (usually index 0) and truncate longer ones to a maximum length.

The key insight is that the LSTM should ignore padding tokens. PyTorch's pack_padded_sequence and pad_packed_sequence utilities let you efficiently skip padding during the LSTM computation, which is important for correctness — otherwise the model will update its hidden state based on meaningless padding, distorting the final h_T.

import torch
from torch.nn.utils.rnn import pad_sequence

def text_pipeline(text):
    return vocab(tokenizer(text))[:256]  # Truncate to 256 tokens

def label_pipeline(label):
    return 1 if label == 'pos' else 0

def collate_batch(batch):
    labels, texts, lengths = [], [], []
    for label, text in batch:
        labels.append(label_pipeline(label))
        processed = torch.tensor(text_pipeline(text), dtype=torch.long)
        texts.append(processed)
        lengths.append(len(processed))
    texts = pad_sequence(texts, batch_first=True, padding_value=1)  # 1 = <pad>
    return torch.tensor(labels), texts, torch.tensor(lengths)

Embedding Layer: Words to Dense Vectors

Integer word indices cannot be fed directly into an LSTM — we need to convert them to continuous vectors. An embedding layer nn.Embedding(vocab_size, embedding_dim) is a lookup table that maps each word index to a dense vector of floats.

Common embedding dimensions are 100, 200, or 300. The embeddings are learnable parameters that get updated during training alongside the LSTM weights. Alternatively, you can initialise them from pre-trained word vectors like GloVe or Word2Vec, which dramatically speeds up training on small datasets by providing pre-learned semantic relationships between words.

import torch.nn as nn

vocab_size = 25000
embedding_dim = 100
hidden_size = 128
output_dim = 1  # Binary classification: positive/negative

embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx=1)

# Optional: load pre-trained GloVe vectors
# from torchtext.vocab import GloVe
# glove = GloVe(name='6B', dim=100)
# embedding.weight.data.copy_(glove.vectors)

print('Embedding weight shape:', embedding.weight.shape)  # (25000, 100)

Building the SentimentLSTM Model

The complete model architecture has three parts: an embedding layer, an LSTM, and a fully connected head. The embedding converts token indices to vectors, the LSTM processes the sequence, and the linear layer maps the final hidden state to a single logit.

We add dropout after the embedding and before the linear layer to regularise the model. For the output, we apply a sigmoid to get a probability between 0 and 1. Training uses Binary Cross-Entropy loss since this is binary classification.

import torch
import torch.nn as nn

class SentimentLSTM(nn.Module):
    def __init__(self, vocab_size, embed_dim, hidden_size, num_layers, dropout):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=1)
        self.lstm = nn.LSTM(embed_dim, hidden_size, num_layers,
                            batch_first=True, dropout=dropout)
        self.dropout = nn.Dropout(dropout)
        self.fc = nn.Linear(hidden_size, 1)

    def forward(self, text):
        embedded = self.dropout(self.embedding(text))  # (B, T, embed_dim)
        output, (h_n, c_n) = self.lstm(embedded)
        last_hidden = self.dropout(h_n[-1])             # Final layer hidden state
        return self.fc(last_hidden).squeeze(1)          # (B,) logits

Training Setup: Loss and Optimizer

For binary classification we use nn.BCEWithLogitsLoss, which combines sigmoid activation and binary cross-entropy loss in a single numerically stable operation. This is preferred over applying sigmoid manually followed by nn.BCELoss.

We use the Adam optimiser with a learning rate of 1e-3. Adam adapts learning rates per parameter, making it well-suited for sparse gradients that arise from embeddings (only the embeddings for words that appear in the batch receive gradient updates). Use gradient clipping with torch.nn.utils.clip_grad_norm_ to prevent exploding gradients, a common issue in RNN training.

import torch
import torch.nn as nn
import torch.optim as optim

model = SentimentLSTM(
    vocab_size=25000, embed_dim=100,
    hidden_size=128, num_layers=2, dropout=0.3
)
optimizer = optim.Adam(model.parameters(), lr=1e-3)
criterion = nn.BCEWithLogitsLoss()

# Gradient clipping prevents exploding gradients in RNNs
MAX_GRAD_NORM = 1.0

print('Model parameters:', sum(p.numel() for p in model.parameters()))

The Training Loop for Sentiment LSTM

The training loop for sequence-to-one classification follows the standard PyTorch pattern: forward pass, compute loss, backpropagate, clip gradients, update parameters. We also compute accuracy by comparing predicted class (sigmoid output > 0.5) to ground truth labels.

One important consideration is moving both the model and batch tensors to the same device (CPU or GPU). Using model.to(device) and tensor.to(device) ensures consistent computation. On a GPU, training a two-layer LSTM on IMDB typically takes 2-5 minutes per epoch.

def train_epoch(model, loader, optimizer, criterion, device):
    model.train()
    total_loss, correct = 0.0, 0
    for labels, texts, lengths in loader:
        labels, texts = labels.float().to(device), texts.to(device)
        optimizer.zero_grad()
        predictions = model(texts)                    # Forward pass
        loss = criterion(predictions, labels)         # Compute loss
        loss.backward()                               # Backprop
        torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)  # Clip
        optimizer.step()                              # Update weights
        total_loss += loss.item()
        preds = (torch.sigmoid(predictions) > 0.5).float()
        correct += (preds == labels).sum().item()
    return total_loss / len(loader), correct / len(loader.dataset)

Evaluation and Monitoring Progress

During evaluation we call model.eval() and wrap forward passes in torch.no_grad() to disable dropout and gradient tracking. This gives us an unbiased view of generalisation performance.

We track four numbers across epochs: train loss, validation loss, train accuracy, and validation accuracy. If train accuracy climbs but validation accuracy stagnates, the model is overfitting — increase dropout or reduce model size. If both are low, the model is underfitting — add LSTM layers or increase hidden size.

def evaluate(model, loader, criterion, device):
    model.eval()
    total_loss, correct = 0.0, 0
    with torch.no_grad():
        for labels, texts, lengths in loader:
            labels, texts = labels.float().to(device), texts.to(device)
            predictions = model(texts)
            loss = criterion(predictions, labels)
            total_loss += loss.item()
            preds = (torch.sigmoid(predictions) > 0.5).float()
            correct += (preds == labels).sum().item()
    return total_loss / len(loader), correct / len(loader.dataset)

# Training run
for epoch in range(5):
    tr_loss, tr_acc = train_epoch(model, train_loader, optimizer, criterion, device)
    vl_loss, vl_acc = evaluate(model, val_loader, criterion, device)
    print(f'Epoch {epoch+1}: Train Acc={tr_acc:.3f}, Val Acc={vl_acc:.3f}')

Expected Performance and Common Mistakes

A well-tuned LSTM sentiment classifier on IMDB typically achieves 85-90% test accuracy. Common mistakes that hurt performance include: not setting model.eval() during evaluation (dropout remains active), not zeroing gradients before backprop, and training for too few epochs before the embeddings converge.

Another pitfall is using the same vocabulary index for unknown words and padding. Separate <unk> (index 0) and <pad> (index 1) tokens and pass padding_idx=1 to the embedding layer so pad embeddings are always zero and their gradients are suppressed during training.

# Common mistakes checklist:
# 1. Missing model.eval() before evaluation
# 2. Missing optimizer.zero_grad() before backward
# 3. Applying sigmoid twice (using BCELoss instead of BCEWithLogitsLoss)
# 4. Not handling unknown words (<unk> token)
# 5. Padding and unknown sharing the same index

# Correct: separate special tokens
vocab = build_vocab_from_iterator(
    yield_tokens(train_iter),
    specials=['<unk>', '<pad>'],  # 0 and 1
    min_freq=2                   # Discard rare words
)

Making Predictions on New Text

To classify new reviews at inference time, we apply the same preprocessing pipeline used during training: tokenise, convert to indices, truncate, and add a batch dimension. The model returns a logit; applying sigmoid converts it to a probability.

A prediction score above 0.5 indicates positive sentiment. You can adjust this threshold: lowering it to 0.3 increases recall for positive reviews at the cost of precision. Always apply model.eval() and torch.no_grad() during inference to disable dropout and avoid unnecessary gradient computation.

def predict_sentiment(model, text, vocab, tokenizer, device, max_len=256):
    model.eval()
    tokens = vocab(tokenizer(text))[:max_len]
    tensor = torch.tensor(tokens, dtype=torch.long).unsqueeze(0).to(device)
    with torch.no_grad():
        logit = model(tensor)
        prob = torch.sigmoid(logit).item()
    return 'positive' if prob > 0.5 else 'negative', prob

label, score = predict_sentiment(
    model,
    'This film was absolutely brilliant. Loved every scene!',
    vocab, tokenizer, device
)
print(f'Sentiment: {label} ({score:.3f})')

LSTM vs BERT for Sentiment Analysis

While LSTMs were state-of-the-art for NLP until around 2018, transformer-based models like BERT now dominate most NLP benchmarks. BERT achieves ~93-95% on IMDB, compared to LSTM's ~87-90%.

However, LSTMs remain relevant for several reasons: they are far more computationally efficient for very long sequences (transformers scale quadratically with sequence length), they work well on time-series data where the sequential nature is intrinsic, and they have a much smaller memory footprint. Understanding LSTMs also provides the conceptual foundation needed to understand how attention mechanisms improve upon them.

Quick Check

Test your understanding of LSTM-based sequence classification from this lesson.

Lesson Recap

In this lesson you learned: sequence-to-one architecture reads an entire input sequence and maps it to a single label using the final LSTM hidden state, text preprocessing pipeline requires tokenisation, vocabulary building, and padding before data enters the model, and embedding layers convert discrete word indices into dense learnable vectors that capture semantic meaning. Next up we explore transfer learning, where pre-trained model weights replace training from scratch.

無料で開始

AI チューターと学ぶ Python — 無料

ブラウザでリアルコードを書いて実行し、24/7 の AI チューターから瞬時にサポートを受け、ウェブまたはアプリで続きから学習できます。

コース
30
レッスン
120

よくある質問

「Sequence-to-One:LSTMによる感情分析」レッスンは無料ですか?

はい。「Sequence-to-One:LSTMによる感情分析」の完全なテキストはこのウェブで無料で読めます。インタラクティブに演習し(組み込みコードエディタと24時間対応のAIチューター)、Machine Learning Academyコースの残りをアンロックするには、CoddyKit PROにアップグレードしてください。 Machine Learning Academyコースには全4レッスンが含まれています。

「Sequence-to-One:LSTMによる感情分析」で何を学びますか?

レビューをトークン化し、nn.Embeddingで単語を埋め込み、LSTMに通して、最後の隠れ状態を分類するエンドツーエンドの感情分類器を構築します。 ブラウザで直接実行するハンズオンコードでMachine Learning Academyを演習し、24時間対応のAIチューターがレッスンを進める中での質問に答えます。

Machine Learning Academyを始めるのに経験は必要ですか?

事前経験は必要ありません。CoddyKitのMachine Learning Academyは初級者から上級者向けに構成されているため、ここから始めるか最初から始めて、自分のペースで進むことができます。 これはレッスン4/4です。

「Sequence-to-One:LSTMによる感情分析」レッスンにはどのくらい時間がかかりますか?

ほとんどのCoddyKitレッスンは約5~10分かかります。各レッスンはコンパクトでインタラクティブなので、着実に進歩し、ウェブとアプリ全体で正確に前回の場所から再開できます。

このMachine Learning Academyレッスンでコードを書いて実行できますか?

はい。すべてのMachine Learning Academyレッスンに組み込みコードエディタが含まれているため、ブラウザでリアルコードを書いて実行し、即座のAIフィードバックを取得できます。ローカル設定は不要です。

このコースのすべてのレッスン

  1. Vanilla RNN:隠れ状態と系列の展開
  2. 長い時系列で生じる勾配消失問題
  3. LSTMセル:入力、忘却、出力ゲート
  4. Sequence-to-One:LSTMによる感情分析
← Machine Learning Academyに戻る