iT邦幫忙

2025 iThome 鐵人賽

DAY 22
0
生成式 AI

《AI 新手到職場應用:深度學習 30 天實戰》系列 第 22

RNN 實作:打造一個簡單的文字預測模型

  • 分享至 

  • xImage
  •  

在前一篇文章裡,我們花了很多篇幅來理解 RNN(Recurrent Neural Network)的理論基礎,
包含它為什麼適合處理文字語音時間序列這類資料。

而在今天,我們要透過一個小型的實作來感受 RNN 的魅力,
並嘗試讓電腦學會「文字接龍」:
給定一個文字開頭,模型能夠自動預測接下來的文字。

透過這個簡單卻又直觀的練習,希望其能夠幫助我們看到 RNN 是怎麼
一步一步「記住上下文」並進行預測的。


1. 資料準備:選擇文字來源

在這個例子裡,我們會使用一段簡單的英文文字資料。
由於我們不是在做大規模訓練,所以即使資料量不大,模型仍能展現出「預測」的效果。

而在今天的訓練中,我們直接使用 TensorFlow 內建的簡單文字。
如果是在實務上,我們也可以換成小說、歌詞,甚至是自己的筆記之類的。

程式碼如下:

import tensorflow as tf
import numpy as np

# 載入資料
text = "hello world! this is a simple text for testing recurrent neural networks."

# 建立字元字典
vocab = sorted(set(text))
char2idx = {u:i for i, u in enumerate(vocab)}
idx2char = np.array(vocab)

# 把文字轉換成數字
text_as_int = np.array([char2idx[c] for c in text])

在這裡,vocab 就是所有出現過的字元集合
例如空格、字母、驚嘆號。

char2idx字元到數字的對照表,而 text_as_int 則是整段「文字轉換成數字」的結果。

我們也會稱這個過程叫做「編碼」(encoding)。


2. 建立輸入序列

RNN 模型需要學習「前後文的關係」,所以我們要把文字切成一小段一小段的序列。

舉個很簡單的例子,如果輸入 "hello worl",那麼模型的目標就是預測下一個字 "d"。
這樣訓練下來,模型才能逐步學會語言的連貫性。

重要提醒:

因為序列長度必須小於文字總長度,否則我們就無法建立有效的訓練資料。
在這個例子中,我們的文字只有 73 個字元,所以序列長度應該設定得更小。

而此段的程式碼如下:

# 設定序列長度(必須小於文字總長度)
seq_length = 10  
examples_per_epoch = len(text)//(seq_length+1)

# 使用 tf.data 建立 dataset
char_dataset = tf.data.Dataset.from_tensor_slices(text_as_int)

# 轉成序列
sequences = char_dataset.batch(seq_length+1, drop_remainder=True)

# 建立輸入/標籤 pair
def split_input_target(chunk):
    input_text = chunk[:-1]
    target_text = chunk[1:]
    return input_text, target_text

dataset = sequences.map(split_input_target)

這裡的 split_input_target 很關鍵。它會把每個序列切成「輸入」和「標籤」。
例如 [h, e, l, l, o] → 輸入 [h, e, l, l],標籤 [e, l, l, o]。

換個說法,就是要模型學會「看到前一個字,預測下一個字」。


3. 建立資料批次

在模型訓練時,我們不會一次把所有資料都送進去,而是把資料分成一個一個批次(batch)。

這樣做有兩個好處:
第一,可以避免記憶體不足
第二,可以讓模型在每個批次中隨機化資料,避免學習順序造成偏差。

# 設定批次大小
BATCH_SIZE = 2
BUFFER_SIZE = 10

dataset = dataset.shuffle(BUFFER_SIZE).batch(BATCH_SIZE, drop_remainder=True)

這裡的 BATCH_SIZE 設定為 2,也就是模型每次會處理 2 組輸入輸出配對。
BUFFER_SIZE 設為 10,代表在建立批次前會隨機打亂這些資料,確保模型學習過程更加穩定。drop_remainder=True 則是為了確保每個批次大小一致,避免最後一批資料數量不足。


4. 建立模型:RNN 的核心

接下來就是進入最關鍵的部分,我們要來建立一個簡單的 RNN 模型。

在建立模型之前,我們先設定幾個重要的參數:

vocab_size字典的大小,也就是所有可能的字元數量;
embedding_dim字元向量的維度
rnn_units 是 RNN 層中神經元的數量

# 設定模型參數(針對小資料集調整)
vocab_size = len(vocab)
embedding_dim = 32   
rnn_units = 64     

# 建立模型
model = tf.keras.Sequential([
    tf.keras.layers.Embedding(vocab_size, embedding_dim),
    tf.keras.layers.SimpleRNN(rnn_units,
                              return_sequences=True,
                              recurrent_initializer='glorot_uniform'),
    tf.keras.layers.Dense(vocab_size)
])

在這個模型中,第一層是 Embedding 層
它的功能是把文字編碼(數字形式)轉換成「向量」表示,
這樣模型才能更好地理解字與字之間的關係。

接著是 SimpleRNN 層,這是 RNN 的核心部分,它能逐字處理序列,
並且把過去的狀態傳遞下去,讓模型能「記得」前後文。

最後一層則是 Dense 層,它會輸出對應字典大小的機率分佈,也就是模型對「下一個字元」的預測。


5. 模型訓練

模型架構完成之後,我們就需要定義損失函數,並且開始訓練模型。
這裡我們使用 sparse categorical crossentropy 來計算模型預測與真實答案之間的差距
(這個在之前的實作文章內有講過),並且選擇 Adam 這個常見的優化器來更新權重。

由於資料量很小,我們需要增加訓練的 epoch 數量,讓模型有更多機會學習。

讓我們先來看程式碼:

# 定義損失函數
def loss(labels, logits):
    return tf.keras.losses.sparse_categorical_crossentropy(labels, logits, from_logits=True)

# 編譯模型
model.compile(optimizer='adam', loss=loss)

# 訓練模型(增加訓練次數)
history = model.fit(dataset, epochs=30)

在這個訓練過程中,我們設定讓模型跑 30 個 epoch,
也是由於資料量很少,需要更多的訓練輪數才能讓模型充分學習。

而隨著訓練的進行,如果發現損失值逐漸下降,代表模型在逐步學會如何預測序列,
不過要注意的是,由於訓練資料只有 73 個字元,生成的文字很可能不會太流暢,
但至少能看見模型學到了字元分布和一些基本的模式。

我認為這是可以讓我們花比較少時間訓練,但還是能學習到東西的好方法。


6. 文字生成:讓模型「寫文章」

訓練完成後,接下來就是我覺得整個實作最有趣的部分 —— 讓模型生成文字

在這裡,我們會給定一個起始字串,模型會依據這個字串一步一步預測下一個字元,
然後將預測結果接在輸入後面,再繼續生成,直到達到我們設定的長度。

話不多說,上程式碼:

# 文字生成函數
def generate_text(model, start_string):
    num_generate = 200
    input_eval = [char2idx[s] for s in start_string]
    input_eval = tf.expand_dims(input_eval, 0)

    text_generated = []
    temperature = 1.0

    for i in range(num_generate):
        predictions = model(input_eval)
        predictions = tf.squeeze(predictions, 0)
        
        # 只取最後一個時間步的預測
        predictions = predictions[-1, :] / temperature
        predicted_id = tf.random.categorical(tf.expand_dims(predictions, 0), num_samples=1)[0,0].numpy()

        # 將新預測的字元加入輸入序列
        input_eval = tf.concat([input_eval, tf.expand_dims([predicted_id], 0)], axis=1)
        text_generated.append(idx2char[predicted_id])

    return start_string + ''.join(text_generated)

# 測試生成文字
print(generate_text(model, start_string="hello"))

這裡的 start_string 是我們給模型的起始字,例如 "hello"。
temperature 參數則控制生成文字的隨機性:
如果溫度很低(例如 0.2),模型生成的文字會比較保守,幾乎只會選最可能的字;
反之如果溫度很高(例如 1.5),模型生成的文字會更有創意,但同時也更隨機。

透過這個迴圈,模型每次會預測一個字元,然後把它加回輸入,再繼續下一輪的預測。
如此一來,我們就能得到一段連續的文字。


7.成果演示

首先先讓我呈現一次完整的程式碼:

import tensorflow as tf
import numpy as np

# 1. 準備資料
text = "hello world! this is a simple text for testing recurrent neural networks."
vocab = sorted(set(text))
char2idx = {u:i for i, u in enumerate(vocab)}
idx2char = np.array(vocab)
text_as_int = np.array([char2idx[c] for c in text])

# 2. 建立輸入序列
seq_length = 10  # 修改:從100改為10
examples_per_epoch = len(text)//(seq_length+1)
char_dataset = tf.data.Dataset.from_tensor_slices(text_as_int)
sequences = char_dataset.batch(seq_length+1, drop_remainder=True)

def split_input_target(chunk):
    input_text = chunk[:-1]
    target_text = chunk[1:]
    return input_text, target_text

dataset = sequences.map(split_input_target)

# 3. 建立資料批次
BATCH_SIZE = 2  # 修改:從64改為2
BUFFER_SIZE = 10  # 修改:從10000改為10
dataset = dataset.shuffle(BUFFER_SIZE).batch(BATCH_SIZE, drop_remainder=True)

# 4. 建立模型
vocab_size = len(vocab)
embedding_dim = 32   # 修改:從256改為32
rnn_units = 64       # 修改:從1024改為64

model = tf.keras.Sequential([
    tf.keras.layers.Embedding(vocab_size, embedding_dim),  # 修改:移除batch_input_shape
    tf.keras.layers.SimpleRNN(rnn_units,
                              return_sequences=True,
                              recurrent_initializer='glorot_uniform'),  # 修改:移除stateful=True
    tf.keras.layers.Dense(vocab_size)
])

# 5. 模型訓練
def loss(labels, logits):
    return tf.keras.losses.sparse_categorical_crossentropy(labels, logits, from_logits=True)

model.compile(optimizer='adam', loss=loss)
history = model.fit(dataset, epochs=30)  # 修改:從10改為30

# 6. 文字生成
def generate_text(model, start_string):
    num_generate = 50  # 修改:從200改為50
    input_eval = [char2idx[s] for s in start_string]
    input_eval = tf.expand_dims(input_eval, 0)

    text_generated = []
    temperature = 1.0

    for i in range(num_generate):
        predictions = model(input_eval)
        predictions = tf.squeeze(predictions, 0)
        predictions = predictions[-1, :] / temperature  # 修改:只取最後一個時間步
        predicted_id = tf.random.categorical(tf.expand_dims(predictions, 0), num_samples=1)[0,0].numpy()

        input_eval = tf.concat([input_eval, tf.expand_dims([predicted_id], 0)], axis=1)  # 修改:使用concat
        text_generated.append(idx2char[predicted_id])

    return start_string + ''.join(text_generated)

print(generate_text(model, start_string="hello"))

接著,我們就來看模型在訓練之後,都學到了什麼:

https://ithelp.ithome.com.tw/upload/images/20251002/201691969LYkbdsVP1.png

最後一行「hellosewrrddn txoxumdieneow.rdctnexwirddiheitx.lkmfatix
就是我們訓練後模型所生成的結果。

這個模型學到了什麼?

在觀察結果後,我發現模型成功學到了幾個重要的規律:
首先,它知道要使用我訓練資料中出現過的字元,
像是 e、l、r、o、t、w、x、i、n 這些字母都在原始文字中出現過。

再來,它也知道要用空格來分隔單字,而且還會適時使用句點作為標點符號。

最後,它注意到某些字母應該出現得比較頻繁,
例如 e、r、t 這些高頻字母在生成的文字中也出現得比較多。

更有趣的是,我們可以觀察到模型生成了一些「看起來像單字」的組合,
例如 sewrrddntxoxumdieneow
雖然這些不是真正的英文單字,但它們的長度和結構看起來有那麼一點英文單字的感覺。
這表示模型隱約學到了「單字應該有一定長度」和「字母不是完全隨機排列」這些概念。

模型沒學到什麼?為什麼

不過模型完全無法拼出真實的英文單字。
它不知道 hello 後面通常會接 world,
也不知道 this 或 simple 這些訓練資料中出現過的完整單字。
模型更不理解任何語法或語義,生成的句子沒有任何實際意義。

其實原因也很簡單,就是訓練資料太少了。
73 個字元大約只有 10 個單字,模型根本沒有足夠的範例來學習真正的語言規律。
這就像是你只看過 10 個中文詞彙,然後要你寫一篇文章,當然寫不出來。

模型現在處於一種「知道遊戲規則,但不知道怎麼玩」的狀態。
它知道要用這些字母、要用空格、要有標點符號,
但它不知道這些元素該如何正確組合成有意義的語言。

這個結果我可以學到什麼:

這個結果其實很有教學價值。它清楚展示了深度學習的一個核心原則:
模型的學習能力完全受限於資料量

即使是最簡單的語言生成任務,也需要大量的訓練資料才能學到有意義的模式。
這個實驗也讓我們看到 RNN 的學習是分層次的。在資料量極少的情況下,
模型只能學到最表面的統計特徵(字元分布、空格使用)。

如果要學習更深層的規律(單字拼寫、語法結構),則需要指數級增加的資料量。


以上就是我們今天的學習了,
下一篇,我們將開始挑戰 LSTM(長短期記憶網路),
看看它是如何幫助 RNN 克服「遺忘問題」的!


上一篇
認識時間序列與文字:RNN 的核心概念
下一篇
NLP 入門 — 從文字斷詞到向量化表示(Word Embedding)
系列文
《AI 新手到職場應用:深度學習 30 天實戰》24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言