فاز 5 · درس 8

شبکه‌های CNN و RNN برای پردازش متن: پیاده‌سازی TextCNN و LSTM در PyTorch

نوع
ساختنی
زمان
حدود 75 دقیقه
زبان‌ها
Python
پیش‌نیاز
فاز 3 · درس 11 (مقدمه‌ای بر PyTorch)، فاز 5 · درس 03 (امبدینگ کلمات)، فاز 4 · درس 02 (پیاده‌سازی کانولوشن از صفر)
نمایی از یک مسیر ریلی باریک در کنار یک تسمه نقاله موازی با جعبه‌های داده در کارگاهی با نور ملایم
فهرست مطالب
  1. صورت مسئله
  2. مفاهیم کلیدی
  3. پیاده‌سازی
  4. گام 1: پیاده‌سازی TextCNN در PyTorch
  5. گام 2: دسته‌بند LSTM
  6. گام 3: نمایش شهودی محو گرادیان
  7. گام 4: چرا این روش‌ها هم کافی نبودند؟
  8. استفاده از ابزارهای واقعی
  9. تحویل خروجی
  10. تمرین‌ها
  11. اصطلاحات کلیدی
  12. مطالعه‌ی بیشتر

در عصر یکه‌تازی مدل‌های ترنسفورمر، شاید تصور کنید شبکه‌های کانولوشنی (CNN) و بازگشتی (RNN) منسوخ شده‌اند؛ اما در واقعیت مهندسی، همیشه منابع نامحدود ابری یا کارت‌های گرافیک گران‌قیمت در دسترس نیستند. روش‌های سنتی مثل TF-IDF یا Word2Vec ترتیب واژه‌ها را نادیده می‌گرفتند و مدل‌های زبانی عظیم نیز روی سخت‌افزارهای ضعیف یا تلفن‌های همراه برای کارهای ساده‌ای چون دسته‌بندی متن بسیار سنگین‌اند.

این درس به شما نشان می‌دهد چطور با معماری‌های سبکی مثل TextCNN و LSTM، الگوهای ترتیبی و n-gramها را با سرعت بالا پردازش کنید. مدل TextCNN به صورت کاملاً موازی روی CPU یا پردازنده‌های معمولی در چند دقیقه آموزش می‌بیند و انتخابی عالی برای ساخت خطوط مبنا (baseline) و سامانه‌های پردازش بلادرنگ (streaming) به شمار می‌رود.

کدهای این آموزش بر پایه‌ی ماژول‌های استاندارد PyTorch طراحی شده‌اند و بخش اعظم تمرین‌ها بدون نیاز به GPU قدرتمند یا پرداخت هزینه‌های گزاف سرورهای ابری، روی سیستم شخصی اجراپذیر هستند؛ این موضوع برای توسعه‌دهندگانی که با محدودیت دسترسی به زیرساخت‌های ابری بین‌المللی مواجه‌اند اهمیت دوچندانی دارد.

شبکه‌های کانولوشنی n-gramها را یاد می‌گیرند. شبکه‌های بازگشتی به خاطر می‌سپارند. اتنشن (attention) جای هر دو را گرفت. با این حال، هر دو هنوز روی سخت‌افزارهای محدود اهمیت دارند.

صورت مسئله

روش‌های TF-IDF و Word2Vec بردارهایی تخت تولید می‌کردند که ترتیب کلمات را نادیده می‌گرفتند. مدلی که بر پایه‌ی آن‌ها آموزش می‌دید، نمی‌توانست تفاوت dog bites man و man bites dog را تشخیص دهد؛ در حالی که گاهی پیام اصلی دقیقاً در ترتیب کلمات نهفته است.

پیش از ظهور ترنسفورمرها (transformers)، دو خانواده از معماری‌ها این خلأ را پر کردند:

شبکه‌های عصبی کانولوشنی برای متن (TextCNN): کانولوشن‌های یک‌بعدی (1D convolutions) را روی دنباله‌ای از امبدینگ (embedding) کلمات اعمال می‌کنند. فیلتری با عرض 3 در عمل یک شناساگر یادگیرنده‌ی trigram است: روی 3 کلمه قرار می‌گیرد و یک امتیاز در خروجی می‌دهد. با چیدن فیلترهایی با عرض‌های مختلف (2، 3، 4 و 5) در کنار هم، الگوهایی در مقیاس‌های گوناگون شناسایی می‌شوند. در نهایت، با یک لایه‌ی بیشینه‌استخر (max-pool)، خروجی به بازنمایی با اندازه‌ی ثابت تبدیل می‌شود؛ ساختاری تخت، موازی و سریع.

شبکه‌های عصبی بازگشتی (RNN ،LSTM ،GRU): توکن‌ها را یکی‌یکی پردازش می‌کنند و یک وضعیت پنهان (hidden state) نگه می‌دارند تا اطلاعات را رو به جلو منتقل کنند. ترتیبی هستند، حافظه دارند و ورودی‌هایی با طول متغیر را می‌پذیرند. این شبکه‌ها از سال 2014 تا 2017 بر مدل‌سازی دنباله‌ها مسلط بودند؛ تا این‌که سروکله‌ی اتنشن پیدا شد.

در این درس هر دو معماری را می‌سازیم و سپس به نقصی اشاره می‌کنیم که انگیزه‌ی خلق اتنشن شد.

مفاهیم کلیدی

مدل TextCNN (کیم، 2014): ابتدا توکن‌ها امبد می‌شوند. سپس یک کانولوشن یک‌بعدی با عرض k فیلتر خود را روی k-gramهای متوالی از امبدینگ‌ها می‌لغزاند و یک نقشه‌ی ویژگی (feature map) می‌سازد. عملیات max-pooling سراسری روی این نقشه، قوی‌ترین فعال‌سازی را برمی‌دارد. خروجی‌های حاصل از max-pooling چند فیلتر با عرض‌های مختلف به هم متصل (concatenate) و به سربرگ دسته‌بند (classifier head) داده می‌شوند.

چرا جواب می‌دهد؟ هر فیلتر در اصل یک n-gram یادگیرنده است. از آن‌جا که max-pooling نسبت به جایگاه مستقل عمل می‌کند (position-invariant)، عبارت «اصلاً خوب نیست» چه در ابتدای نقد بیاید و چه در میانه‌ی آن، همان ویژگی را فعال می‌کند. اگر 3 عرض فیلتر با 100 فیلتر برای هر کدام داشته باشید، 300 شناساگر n-gram یادگرفته‌شده در اختیار خواهید داشت. آموزش این شبکه کاملاً موازی انجام می‌شود و وابستگی ترتیبی ندارد.

شبکه‌ی بازگشتی (RNN): در هر گام زمانی t، وضعیت پنهان برابر است با: h_t = f(W * x_t + U * h_{t-1} + b). مقادیر W و U و b در تمام طول زمان مشترک‌اند. وضعیت پنهان در زمان T خلاصه‌ای از کل پیشوند ورودی است. برای دسته‌بندی، روی h_1 ... h_T عملیات تجمیع (pooling به صورت max ،mean یا برداشتن آخرین وضعیت) انجام می‌دهیم.

شبکه‌های RNN ساده با مشکل محو گرادیان (vanishing gradient) روبه‌رو هستند. LSTM گیت‌هایی اضافه می‌کند که تصمیم می‌گیرند چه چیزی فراموش شود، چه چیزی ذخیره گردد و چه مقداری در خروجی بیاید؛ به این ترتیب گرادیان‌ها در طول دنباله‌های طولانی پایدار می‌مانند. معماری GRU ساختار LSTM را به دو گیت ساده می‌کند و با پارامترهای کمتر، عملکردی مشابه ارائه می‌دهد.

شبکه‌های بازگشتی دوطرفه (Bidirectional RNNs): یک RNN را رو به جلو و دیگری را رو به عقب اجرا می‌کنند و وضعیت‌های پنهان آن‌ها را کنار هم می‌گذارند. بنابراین بازنمایی هر توکن هم بافت متنی سمت چپ را می‌بیند و هم سمت راست را. این ویژگی برای کارهای برچسب‌گذاری توکن‌ها حیاتی است.

RNN بازشده (Unrolled)طول را بکشید
RNN UNROLLEDA recurrent network is one cell applied at every time step, sharing the same weights. Unrolled across time it becomes a chain: each cell folds the new input into the previous hidden state with tanh, then passes the result forward. The final state has seen the whole sequence, which is also why long sequences make the gradient hard to carry back.h0x00.46h1x10.03h2x20.39h3x30.32h4x40.04
یک شبکه بازگشتی همان یک سلول است که در هر گام زمانی اعمال می‌شود و وزن‌های یکسانی دارد. با باز شدن در طول زمان، به یک زنجیره تبدیل می‌شود: هر سلول با استفاده از tanh ورودی جدید را در وضعیت پنهان قبلی ادغام می‌کند و سپس نتیجه را به جلو می‌فرستد. وضعیت نهایی کل توالی را دیده است، به همین دلیل است که در توالی‌های طولانی، بازگرداندن گرادیان دشوار می‌شود.

پیاده‌سازی

گام 1: پیاده‌سازی TextCNN در PyTorch

import torch
import torch.nn as nn
import torch.nn.functional as F


class TextCNN(nn.Module):
    def __init__(self, vocab_size, embed_dim, n_classes, filter_widths=(2, 3, 4), n_filters=64, dropout=0.3):
        super().__init__()
        self.embed = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
        self.convs = nn.ModuleList([
            nn.Conv1d(embed_dim, n_filters, kernel_size=k)
            for k in filter_widths
        ])
        self.dropout = nn.Dropout(dropout)
        self.fc = nn.Linear(n_filters * len(filter_widths), n_classes)

    def forward(self, token_ids):
        x = self.embed(token_ids).transpose(1, 2)
        pooled = []
        for conv in self.convs:
            c = F.relu(conv(x))
            p = F.max_pool1d(c, c.size(2)).squeeze(2)
            pooled.append(p)
        h = torch.cat(pooled, dim=1)
        return self.fc(self.dropout(h))

دستور transpose(1, 2) ابعاد تانسور را از [batch, seq_len, embed_dim] به [batch, embed_dim, seq_len] تغییر می‌دهد؛ چون nn.Conv1d محور میانی را کانال در نظر می‌گیرد. خروجی حاصل از لایه‌ی pooling فارغ از طول ورودی، همیشه اندازه‌ای ثابت دارد.

گام 2: دسته‌بند LSTM

class LSTMClassifier(nn.Module):
    def __init__(self, vocab_size, embed_dim, hidden_dim, n_classes, bidirectional=True, dropout=0.3):
        super().__init__()
        self.embed = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
        self.lstm = nn.LSTM(embed_dim, hidden_dim, batch_first=True, bidirectional=bidirectional)
        factor = 2 if bidirectional else 1
        self.dropout = nn.Dropout(dropout)
        self.fc = nn.Linear(hidden_dim * factor, n_classes)

    def forward(self, token_ids):
        x = self.embed(token_ids)
        out, _ = self.lstm(x)
        pooled = out.max(dim=1).values
        return self.fc(self.dropout(pooled))

در این‌جا روی کل دنباله max-pooling می‌زنیم، نه این‌که فقط آخرین وضعیت را برداریم. در مسائل دسته‌بندی، معمولاً max-pooling بهتر از انتخاب صرفاً آخرین وضعیت پنهان عمل می‌کند؛ چرا که در دنباله‌های طولانی، اطلاعات انتهای متن روی آخرین وضعیت مسلط می‌شوند.

گام 3: نمایش شهودی محو گرادیان

یک RNN ساده بدون مکانیزم گیت نمی‌تواند وابستگی‌های دوربرد را یاد بگیرد. مسئله‌ای ساختگی را در نظر بگیرید: پیش‌بینی این‌که آیا توکن A اصلاً در دنباله ظاهر شده است یا نه. اگر A در جایگاه 1 باشد و طول دنباله 100 توکن باشد، گرادیانِ تابع زیان (loss) باید در مسیر برگشت از دلِ 99 بار ضرب پیاپی در وزن بازگشتی عبور کند. اگر مقدار این وزن کمتر از 1 باشد، گرادیان محو می‌شود؛ اگر بیشتر از 1 باشد، منفجر خواهد شد.

def vanishing_gradient_sim(seq_len, recurrent_weight=0.9):
    import math
    return math.pow(recurrent_weight, seq_len)


# At weight=0.9 over 100 steps:
#   0.9 ^ 100 ≈ 2.7e-5
# گرادیان از گام 100 به گام 1 عملاً صفر است.

شبکه‌های LSTM با معرفی وضعیت سلول (cell state) این مشکل را حل می‌کنند؛ داده‌ها در طول شبکه فقط از طریق عملیات جمعی جریان می‌یابند (گیت فراموشی مقادیر را با ضرب مقیاس‌بندی می‌کند، اما گرادیان‌ها همچنان بدون مانع از این «بزرگراه» عبور می‌کنند). مدل‌های GRU نیز با پارامترهای کمتر سازوکار مشابهی دارند. هر دو مدل آموزش پایداری را برای دنباله‌های بیش از 100 گام فراهم می‌کنند.

گام 4: چرا این روش‌ها هم کافی نبودند؟

حتی با وجود LSTMها، 3 مشکل اصلی پابرجا ماند:

  1. گلوگاه ترتیبی: آموزش یک RNN روی دنباله‌ای به طول 1000 توکن، به 1000 گام متوالیِ پیش‌رو و پس‌رو نیاز دارد. امکان موازی‌سازی در طول محور زمان وجود ندارد.
  2. بردار بافت با طول ثابت در معماری‌های انکودر-دیکودر: دیکودر فقط آخرین وضعیت پنهان انکودر را می‌بیند که فشرده‌شده‌ی کل ورودی است؛ بنابراین در ورودی‌های طولانی جزئیات از دست می‌روند. درس 09 مستقیماً به این موضوع می‌پردازد.
  3. سقف دقت در وابستگی‌های طولانی: گرچه LSTMها بسیار بهتر از RNNهای ساده عمل می‌کنند، انتقال اطلاعات مشخص در فواصل بیش از 200 گام همچنان برایشان دشوار است.

اتنشن هر 3 مشکل را حل کرد. ترنسفورمرها اتصالات بازگشتی را کاملاً کنار گذاشتند. درس 10 نقطه‌ی عطف این ماجراست.

استفاده از ابزارهای واقعی

ماژول‌های nn.LSTM ،nn.GRU و nn.Conv1d در PyTorch کاملاً آماده‌ی استفاده در محیط‌های عملیاتی هستند. کد آموزش آن‌ها نیز استاندارد و سرراست است.

کتابخانه‌ی Hugging Face امبدینگ‌های ازپیش‌آموزش‌دیده‌ای ارائه می‌دهد که می‌توانید مستقیماً به عنوان لایه‌ی ورودی متصل کنید:

from transformers import AutoModel

encoder = AutoModel.from_pretrained("bert-base-uncased")
for param in encoder.parameters():
    param.requires_grad = False


class BertCNN(nn.Module):
    def __init__(self, n_classes, filter_widths=(2, 3, 4), n_filters=64):
        super().__init__()
        self.encoder = encoder
        self.convs = nn.ModuleList([nn.Conv1d(768, n_filters, kernel_size=k) for k in filter_widths])
        self.fc = nn.Linear(n_filters * len(filter_widths), n_classes)

    def forward(self, input_ids, attention_mask):
        with torch.no_grad():
            out = self.encoder(input_ids=input_ids, attention_mask=attention_mask).last_hidden_state
        x = out.transpose(1, 2)
        pooled = [F.max_pool1d(F.relu(conv(x)), kernel_size=conv(x).size(2)).squeeze(2) for conv in self.convs]
        return self.fc(torch.cat(pooled, dim=1))

چک‌لیست «چه زمانی مدل با محدودیت‌های مسئله همخوانی دارد»:

  • استنتاج روی لبه یا دستگاه (Edge / on-device): ترکیب TextCNN با امبدینگ‌های GloVe بین 10 تا 100 برابر کوچک‌تر از ترنسفورمر است. اگر مقصد استقرار شما گوشی موبایل است، این پشته انتخاب اصلی شماست.
  • دسته‌بندی بلادرنگ و استریمینگ (Streaming): شبکه‌ی RNN توکن‌ها را یکی‌یکی پردازش می‌کند؛ در حالی که ترنسفورمرها به کل دنباله نیاز دارند. برای متن‌های ورودیِ بلادرنگ، LSTMها هنوز دست بالا را دارند.
  • مدل‌های بسیار کوچک به عنوان خط مبنا (Baseline): تکرار و آزمایش سریع روی یک کار جدید؛ یک TextCNN را می‌توانید ظرف 5 دقیقه روی CPU آموزش دهید.
  • برچسب‌گذاری دنباله با داده‌های محدود: معماری BiLSTM-CRF (درس 06) برای 1k تا 10k جمله‌ی برچسب‌خورده در وظیفه‌ی بازشناسی موجودیت‌های نام‌دار (NER)، همچنان یک معماری سطح عملیاتی به شمار می‌رود.

برای هر کار دیگری، سراغ ترنسفورمر بروید.

تحویل خروجی

این محتوا را در مسیر outputs/prompt-text-encoder-picker.md ذخیره کنید:

---
name: text-encoder-picker
description: Pick a text encoder architecture for a given constraint set.
phase: 5
lesson: 08
---

Given constraints (task, data volume, latency budget, deploy target, compute budget), output:

1. Encoder architecture: TextCNN, BiLSTM, BiLSTM-CRF, transformer fine-tune, or "use a pretrained transformer as a frozen encoder + small head".
2. Embedding input: random init, GloVe / fastText frozen, or contextualized transformer embeddings.
3. Training recipe in 5 lines: optimizer, learning rate, batch size, epochs, regularization.
4. One monitoring signal. For RNN/CNN models: attention mechanism absence means they miss long-range deps; check per-length accuracy. For transformers: fine-tuning collapse if LR too high; check train loss.

Refuse to recommend fine-tuning a transformer when data is under ~500 labeled examples without showing that a TextCNN / BiLSTM baseline has plateaued. Flag edge deployment as needing architecture-before-everything.

تمرین‌ها

  1. ساده: یک مدل TextCNN روی یک مجموعه‌داده‌ی ساختگیِ 3 کلاسه آموزش دهید (داده‌ها را خودتان بسازید). بررسی کنید که آیا استفاده از ترکیب عرض فیلترهای (2، 3، 4) میانگین F1 را نسبت به یک عرض تکی (3) بهتر می‌کند یا نه.
  2. متوسط: روش‌های تجمیع max-pool ،mean-pool و last-state را برای دسته‌بند LSTM پیاده‌سازی کنید. آن‌ها را روی یک مجموعه‌داده‌ی کوچک مقایسه کنید؛ ثبت کنید کدام روش برنده می‌شود و فرضیه‌ی خود را درباره‌ی علت آن بنویسید.
  3. سخت: یک مدل برچسب‌گذار BiLSTM-CRF برای شناسایی موجودیت‌های نام‌دار بسازید (با ترکیب درس 06 و این درس). آن را روی CoNLL-2003 آموزش دهید. نتایج را با خط مبنای CRF به‌تنهایی (از درس 06) و نسخه‌ی فاین‌تیون‌شده‌ی BERT مقایسه کنید. زمان آموزش، مصرف حافظه و امتیاز F1 را گزارش دهید.

اصطلاحات کلیدی

اصطلاح در گفتگوها چه می‌گویند معنای واقعی آن
TextCNN مدل CNN برای متن پشته‌ای از کانولوشن‌های 1D روی امبدینگ کلمات همراه با max-pool سراسری. ارائه‌شده توسط Kim (2014).
RNN شبکه‌ی بازگشتی وضعیت پنهانی که در هر گام زمانی به‌روزرسانی می‌شود: h_t = f(W x_t + U h_{t-1}).
LSTM شبکه‌ی بازگشتی مجهز به گیت دارای گیت‌های ورودی، فراموشی و خروجی به همراه یک وضعیت سلول؛ آموزش آن در طول دنباله‌های طولانی پایدار می‌ماند.
GRU مدل ساده‌تر LSTM دارای دو گیت به جای سه گیت؛ دقتی مشابه با تعداد پارامتر کمتر.
Bidirectional دوطرفه اتصال خروجی‌های شبکه‌ی بازگشتی رو به جلو و رو به عقب به یکدیگر. هر توکن هر دو سمت بافت متنی خود را می‌بیند.
Vanishing gradient محو گرادیان (قطع سیگنال آموزش) ضرب مکرر در وزن‌های کوچک‌تر از 1 در RNNهای ساده که باعث می‌شود گرادیان گام‌های اولیه عملاً صفر شود.

مطالعه‌ی بیشتر

خودآزمایی

به سؤال‌ها جواب بدهید تا ببینید چقدر از درس در ذهنتان مانده است.

  1. پیش از درسیک فیلتر کانولوشنی 1D با عرض 3 روی امبدینگ‌های کلمات عملاً چه چیزی را یاد می‌گیرد؟
  2. پیش از درسچرا LSTM از وضعیت سلول (cell state) با برهم‌کنش‌های عمدتاً جمع‌پذیر استفاده می‌کند؟
  3. حین درسچرا TextCNN بعد از لایه کانولوشن از بیشینه‌گیری سراسری (global max-pooling) استفاده می‌کند؟
  4. حین درسمزیت اصلی یک RNN دوطرفه نسبت به RNN یک‌طرفه در برچسب‌گذاری توالی چیست؟
  5. حین درسچرا بیشینه‌گیری روی خروجی‌های یک LSTM اغلب از استفاده صرف از آخرین وضعیت پنهان برای دسته‌بندی بهتر عمل می‌کند؟
  6. پس از درسکدام محدودیت در انکودر-دیکودرهای LSTM/RNN انگیزه اصلی پیدایش اتنشن بود؟
  7. پس از درسچه زمانی در سال 2026 یک مدل TextCNN یا BiLSTM همچنان ترنسفورمر را شکست می‌دهد؟
  8. پس از درسمسئله محو شدن گرادیان در RNNهای ساده چیست؟

سؤالات متداول

چرا TextCNN با وجود ترنسفورمرها هنوز استفاده می‌شود؟

مدل TextCNN به مراتب کوچک‌تر (بین 10 تا 100 برابر نسبت به ترنسفورمرها) و آموزش آن روی CPU بسیار سریع است؛ از این رو گزینه‌ای ایدئال برای دستگاه‌های لبه، موبایل و خطوط مبنای اولیه به حساب می‌آید.

تفاوت اصلی معماری LSTM با RNN ساده چیست؟

شبکه RNN ساده در دنباله‌های بلند با مشکل محو گرادیان مواجه می‌شود؛ اما LSTM با افزودن گیت‌های کنترل و وضعیت سلول (cell state)، گرادیان‌ها را به صورت جمعی پایدار نگه می‌دارد.

چرا در دسته‌بند LSTM به‌جای برگزیدن آخرین وضعیت پنهان از max-pooling استفاده می‌شود؟

در دنباله‌های متنی طولانی، اطلاعات انتهای متن روی آخرین وضعیت پنهان مسلط می‌شوند؛ در نتیجه اعمال max-pooling روی تمام گام‌ها سیگنال‌های قوی سراسر متن را بهتر استخراج می‌کند.

ما رو توی گوگل بیشتر ببین

با افزودن نکست ادمین به «منابع ترجیحی» گوگل، درس‌ها و مطالب ما را در بخش اخبار برتر جستجو بیشتر می‌بینید.