منابع ترجمه · Think Python · فصل ۱۲

تحلیل و تولید متن (فصل ۱۲ Think Python)

۲۶ دقیقه مطالعه · به‌روزرسانی ۲ آبان ۱۴۰۵

منبع اصلی: Think Python, Chapter 12: Text Analysis and Generation — Allen B. Downey

💡 این صفحه مثل یک نوت‌بوک Jupyter است: متغیر و تابعی که در یک سلول تعریف شود، در سلول‌های بعدی همین صفحه در دسترس است. سلول‌ها را به ترتیب اجرا کنید، یا با «⏮ تا اینجا» همهٔ سلول‌های قبلیِ اجرانشده را یک‌جا اجرا کنید.

دو نکته درباره‌ی این ترجمه: (۱) کتاب اصلی برای نمایش تحلیل مارکوف، متن یک ترانه را به کار می‌برد؛ در این ترجمه آن را با یک متن کوتاه چهارخطی از خودم (درباره‌ی گربه و موش) جایگزین کرده‌ام. چون متن عوض شده، خروجی‌ای که برای آن نشان می‌دهم با خروجی کتاب فرق دارد و توضیح بعد از آن را هم متناسب با متن جدید نوشته‌ام. (۲) خروجی‌های نمایش‌داده‌شده‌ی زیر کدها را با اجرای همین کدها (روی متن رمان Dr. Jekyll and Mr. Hyde از پروژه‌ی گوتنبرگ که در مالکیت عمومی است) به دست آورده‌ام. خروجی‌های تصادفی به مقدار seed بستگی دارند و با همان مقدارهای کتاب تولید شده‌اند.

تا اینجا ساختارهای داده‌ی اصلی پایتون – فهرست‌ها، دیکشنری‌ها و چندتایی‌ها – و چند الگوریتم که از آن‌ها استفاده می‌کنند را دیده‌ایم. در این فصل از آن‌ها برای بررسی تحلیل متن و تولید مارکوف (Markov generation) استفاده می‌کنیم:

  • تحلیل متن شیوه‌ای برای توصیف رابطه‌های آماری بین کلمه‌های یک سند است، مثل احتمال اینکه یک کلمه بعد از کلمه‌ی دیگری بیاید، و

  • تولید مارکوف شیوه‌ای برای تولید متن جدید با کلمه‌ها و عبارت‌هایی شبیه متن اصلی است.

این الگوریتم‌ها شبیه بخش‌هایی از یک مدل زبانی بزرگ (LLM) هستند، که مؤلفه‌ی کلیدی یک چت‌بات است.

با شمردن تعداد دفعاتی که هر کلمه در یک کتاب ظاهر می‌شود شروع می‌کنیم. بعد جفت‌های کلمه را بررسی می‌کنیم و فهرستی از کلمه‌هایی که می‌توانند بعد از هر کلمه بیایند می‌سازیم. نسخه‌ی ساده‌ای از یک مولد مارکوف می‌سازیم و در تمرین‌ها فرصت دارید نسخه‌ی کلی‌تری بسازید.

۱۲.۱. کلمه‌های یکتا

به‌عنوان اولین قدم به‌سوی تحلیل متن، یک کتاب – The Strange Case Of Dr. Jekyll And Mr. Hyde نوشته‌ی Robert Louis Stevenson – را می‌خوانیم و تعداد کلمه‌های یکتا را می‌شماریم. دستورالعمل دانلود کتاب در نوت‌بوک این فصل است.

سلول زیر کتاب را از پروژه‌ی گوتنبرگ دانلود می‌کند.

download('https://github.com/AllenDowney/ThinkPython/raw/v3/pg43.txt');

نسخه‌ای که از پروژه‌ی گوتنبرگ در دسترس است اطلاعاتی درباره‌ی کتاب در ابتدا و اطلاعات مجوز در انتها دارد. از clean_file فصل ۸ استفاده می‌کنیم تا این مطالب را حذف کنیم و فایل «پاکیزه»‌ای بنویسیم که فقط متن کتاب را دارد.

def is_special_line(line):
    return line.strip().startswith('*** ')
def clean_file(input_file, output_file):
    reader = open(input_file, encoding='utf-8')
    writer = open(output_file, 'w', encoding='utf-8')

    for line in reader:
        if is_special_line(line):
            break

    for line in reader:
        if is_special_line(line):
            break
        writer.write(line)
        
    reader.close()
    writer.close()
filename = 'dr_jekyll.txt'
clean_file('pg43.txt', filename)

از حلقه‌ی for برای خواندن خط‌ها از فایل و از split برای تقسیم خط‌ها به کلمه‌ها استفاده می‌کنیم. بعد، برای پیگیری کلمه‌های یکتا، هر کلمه را به‌عنوان کلید در یک دیکشنری ذخیره می‌کنیم.

آرگومان کلیدواژه‌ای encoding='utf-8' در کتاب چاپی نیست. فقط در ویندوز لازم است.

unique_words = {}
for line in open(filename, encoding='utf-8'):
    seq = line.split()
    for word in seq:
        unique_words[word] = 1

len(unique_words)
6040

طول دیکشنری تعداد کلمه‌های یکتاست – با این شیوه‌ی شمارش حدود 6000. اما اگر بررسی‌شان کنیم، می‌بینیم بعضی کلمه‌ی معتبر نیستند.

برای مثال، طولانی‌ترین کلمه‌های unique_words را ببینیم. می‌توانیم از sorted برای مرتب کردن کلمه‌ها استفاده کنیم و تابع len را به‌عنوان آرگومان کلیدواژه‌ای بدهیم تا کلمه‌ها بر اساس طول مرتب شوند.

sorted(unique_words, key=len)[-5:]
['chocolate-coloured',
 'superiors—behold!”',
 'coolness—frightened',
 'gentleman—something',
 'pocket-handkerchief.']

اندیس برش، [-5:]، 5 عنصر آخر فهرست مرتب‌شده را که طولانی‌ترین کلمه‌ها هستند انتخاب می‌کند.

فهرست شامل بعضی کلمه‌های واقعاً بلند، مثل «circumscription»، و بعضی کلمه‌های خط‌تیره‌دار، مثل «chocolate-coloured» است. اما بعضی از طولانی‌ترین «کلمه‌ها» در واقع دو کلمه‌اند که با یک خط تیره (dash) از هم جدا شده‌اند. و کلمه‌های دیگر علامت نگارشی مثل نقطه، علامت تعجب و علامت نقل‌قول دارند.

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

۱۲.۲. علامت‌های نگارشی

برای شناسایی کلمه‌های متن، باید دو مسئله را مدیریت کنیم:

  • وقتی یک خط تیره در یک خط ظاهر می‌شود، باید آن را با یک فاصله جایگزین کنیم – آن‌وقت وقتی از split استفاده می‌کنیم، کلمه‌ها جدا می‌شوند.

  • بعد از جدا کردن کلمه‌ها، می‌توانیم از strip برای حذف علامت‌های نگارشی استفاده کنیم.

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

def split_line(line):
    return line.replace('—', ' ').split()

دقت کنید که split_line فقط خط تیره‌ی بلند (dash) را جایگزین می‌کند، نه خط تیره‌ی کوتاه (hyphen) را. این یک مثال است.

split_line('coolness—frightened')
['coolness', 'frightened']

حالا برای حذف علامت‌های نگارشی از ابتدا و انتهای هر کلمه، می‌توانیم از strip استفاده کنیم، اما به فهرستی از نویسه‌هایی نیاز داریم که علامت نگارشی به حساب می‌آیند.

نویسه‌های رشته‌های پایتون به صورت یونیکد (Unicode) هستند، که استانداردی بین‌المللی است و برای نمایش حرف‌های تقریباً هر الفبا، عددها، نمادها، علامت‌های نگارشی و بیشتر به کار می‌رود. ماژول unicodedata تابعی به نام category دارد که می‌توانیم با آن بفهمیم کدام نویسه‌ها علامت نگارشی‌اند. با دادن یک حرف، رشته‌ای با اطلاعات درباره‌ی دسته‌ای که آن حرف در آن قرار دارد برمی‌گرداند.

import unicodedata

unicodedata.category('A')
'Lu'

رشته‌ی دسته‌ی 'A' برابر 'Lu' است – 'L' یعنی حرف است و 'u' یعنی بزرگ است (uppercase).

رشته‌ی دسته‌ی '.' برابر 'Po' است – 'P' یعنی علامت نگارشی است و 'o' یعنی زیردسته‌اش «سایر» (other) است.

unicodedata.category('.')
'Po'

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

punc_marks = {}
for line in open(filename, encoding='utf-8'):
    for char in line:
        category = unicodedata.category(char)
        if category.startswith('P'):
            punc_marks[char] = 1

برای ساختن فهرست علامت‌های نگارشی، می‌توانیم کلیدهای دیکشنری را در یک رشته به هم بچسبانیم.

punctuation = ''.join(punc_marks)
print(punctuation)
.’;,-“”:?—‘!()_

حالا که می‌دانیم کدام نویسه‌های کتاب علامت نگارشی‌اند، می‌توانیم تابعی بنویسیم که یک کلمه را بگیرد، علامت‌های نگارشی را از ابتدا و انتهایش حذف کند و آن را به حروف کوچک تبدیل کند.

def clean_word(word):
    return word.strip(punctuation).lower()

این یک مثال است.

clean_word('“Behold!”')
'behold'

چون strip نویسه‌ها را از ابتدا و انتها حذف می‌کند، کلمه‌های خط‌تیره‌دار را دست‌نخورده می‌گذارد.

clean_word('pocket-handkerchief')
'pocket-handkerchief'

حالا حلقه‌ای که از split_line و clean_word برای شناسایی کلمه‌های یکتای کتاب استفاده می‌کند.

unique_words2 = {}
for line in open(filename, encoding='utf-8'):
    for word in split_line(line):
        word = clean_word(word)
        unique_words2[word] = 1

len(unique_words2)
4005

با این تعریف سخت‌گیرانه‌تر از کلمه، حدود ۴۰۰۰ کلمه‌ی یکتا داریم. و می‌توانیم تأیید کنیم که فهرست طولانی‌ترین کلمه‌ها پاکیزه شده است.

sorted(unique_words2, key=len)[-5:]
['circumscription',
 'unimpressionable',
 'fellow-creatures',
 'chocolate-coloured',
 'pocket-handkerchief']

حالا ببینیم هر کلمه چند بار استفاده شده است.

۱۲.۳. فراوانی کلمه‌ها

حلقه‌ی زیر فراوانی هر کلمه‌ی یکتا را حساب می‌کند.

word_counter = {}
for line in open(filename, encoding='utf-8'):
    for word in split_line(line):
        word = clean_word(word)
        if word not in word_counter:
            word_counter[word] = 1
        else:
            word_counter[word] += 1

اولین باری که یک کلمه را می‌بینیم، فراوانی‌اش را با 1 مقداردهی اولیه می‌کنیم. اگر بعداً همان کلمه را دوباره ببینیم، فراوانی‌اش را افزایش می‌دهیم.

برای دیدن اینکه کدام کلمه‌ها بیشتر ظاهر می‌شوند، می‌توانیم از items برای گرفتن زوج‌های کلید-مقدار word_counter استفاده کنیم و آن‌ها را بر اساس عنصر دوم زوج، یعنی فراوانی، مرتب کنیم. اول تابعی تعریف می‌کنیم که عنصر دوم را انتخاب می‌کند.

def second_element(t):
    return t[1]

حالا می‌توانیم sorted را با دو آرگومان کلیدواژه‌ای به کار ببریم:

  • key=second_element یعنی موردها بر اساس فراوانی کلمه‌ها مرتب می‌شوند.

  • reverse=True یعنی موردها به ترتیب معکوس مرتب می‌شوند، با پرتکرارترین کلمه‌ها در ابتدا.

items = sorted(word_counter.items(), key=second_element, reverse=True)

این پنج کلمه‌ی پرتکرار است.

for word, freq in items[:5]:
    print(freq, word, sep='\t')
1614	the
972	and
941	of
640	to
640	i

در بخش بعد، این حلقه را در یک تابع کپسوله می‌کنیم. و از آن برای نمایش یک ویژگی جدید – پارامترهای اختیاری – استفاده می‌کنیم.

۱۲.۴. پارامترهای اختیاری

از توابع داخلی‌ای که پارامترهای اختیاری می‌گیرند استفاده کرده‌ایم. برای مثال، round پارامتر اختیاری‌ای به نام ndigits دارد که مشخص می‌کند چند رقم اعشار نگه داشته شود.

round(3.141592653589793, ndigits=3)
3.142

اما فقط توابع داخلی نیستند – ما هم می‌توانیم توابعی با پارامتر اختیاری بنویسیم. برای مثال، تابع زیر دو پارامتر word_counter و num می‌گیرد.

def print_most_common(word_counter, num=5):
    items = sorted(word_counter.items(), key=second_element, reverse=True)

    for word, freq in items[:num]:
        print(freq, word, sep='\t')

پارامتر دوم شبیه یک دستور نسبت‌دهی است، اما نیست – پارامتر اختیاری است.

اگر این تابع را با یک آرگومان فراخوانی کنید، num مقدار پیش‌فرض (default value) یعنی 5 را می‌گیرد.

print_most_common(word_counter)
1614	the
972	and
941	of
640	to
640	i

اگر این تابع را با دو آرگومان فراخوانی کنید، آرگومان دوم به‌جای مقدار پیش‌فرض به num نسبت داده می‌شود.

print_most_common(word_counter, 3)
1614	the
972	and
941	of

در آن صورت می‌گوییم آرگومان اختیاری مقدار پیش‌فرض را بازنویسی (override) می‌کند.

اگر تابعی هم پارامتر الزامی و هم پارامتر اختیاری داشته باشد، همه‌ی پارامترهای الزامی باید اول بیایند و بعد پارامترهای اختیاری.

def bad_function(n=5, word_counter):
    return None
SyntaxError: non-default argument follows default argument

۱۲.۵. تفریق دیکشنری‌ها

فرض کنید می‌خواهیم یک کتاب را غلط‌یابی املایی کنیم – یعنی فهرستی از کلمه‌هایی را که ممکن است غلط املایی داشته باشند پیدا کنیم. یک راه برای این کار پیدا کردن کلمه‌هایی در کتاب است که در فهرست کلمه‌های معتبر نیستند. در فصل‌های قبل از فهرستی از کلمه‌ها استفاده کردیم که در بازی‌های کلمه‌ای مثل اسکرابل معتبر شمرده می‌شوند. حالا از این فهرست برای غلط‌یابی متن Robert Louis Stevenson استفاده می‌کنیم.

می‌توانیم این مسئله را مثل تفریق مجموعه‌ها در نظر بگیریم – یعنی می‌خواهیم همه‌ی کلمه‌های یک مجموعه (کلمه‌های کتاب) را که در مجموعه‌ی دیگر (کلمه‌های فهرست) نیستند پیدا کنیم.

سلول زیر فهرست کلمه‌ها را دانلود می‌کند.

download('https://raw.githubusercontent.com/AllenDowney/ThinkPython/v3/words.txt');

مثل قبل، می‌توانیم محتویات words.txt را بخوانیم و آن را به فهرستی از رشته‌ها بشکنیم.

word_list = open('words.txt').read().split()

بعد کلمه‌ها را به‌عنوان کلید در یک دیکشنری ذخیره می‌کنیم تا بتوانیم از عملگر in برای بررسی سریع معتبر بودن یک کلمه استفاده کنیم.

valid_words = {}
for word in word_list:
    valid_words[word] = 1

حالا برای شناسایی کلمه‌هایی که در کتاب هستند اما در فهرست کلمه‌ها نیستند، از subtract استفاده می‌کنیم که دو دیکشنری را به‌عنوان پارامتر می‌گیرد و دیکشنری جدیدی برمی‌گرداند که همه‌ی کلیدهای یکی را که در دیگری نیستند دارد.

def subtract(d1, d2):
    res = {}
    for key in d1:
        if key not in d2:
            res[key] = d1[key]
    return res

این‌طور از آن استفاده می‌کنیم.

diff = subtract(word_counter, valid_words)

برای گرفتن نمونه‌ای از کلمه‌هایی که ممکن است غلط املایی داشته باشند، می‌توانیم پرتکرارترین کلمه‌های diff را چاپ کنیم.

print_most_common(diff)
640	i
628	a
128	utterson
124	mr
98	hyde

پرتکرارترین کلمه‌های «غلط املایی» بیشتر نام‌ها و چند کلمه‌ی تک‌حرفی هستند (آقای Utterson دوست و وکیل دکتر Jekyll است).

اگر کلمه‌هایی را انتخاب کنیم که فقط یک بار ظاهر می‌شوند، احتمال بیشتری دارد که غلط املایی واقعی باشند. می‌توانیم این کار را با پیمایش موردها و ساختن فهرستی از کلمه‌هایی با فراوانی 1 انجام دهیم.

singletons = []
for word, freq in diff.items():
    if freq == 1:
        singletons.append(word)

این چند عنصر آخر فهرست است.

singletons[-5:]
['gesticulated', 'abjection', 'circumscription', 'reindue', 'fearstruck']

بیشترشان کلمه‌های معتبری هستند که در فهرست کلمه‌ها نیستند. اما 'reindue' به نظر غلط املایی 'reinduce' است، پس دست‌کم یک خطای واقعی پیدا کردیم.

۱۲.۶. عددهای تصادفی

به‌عنوان قدمی به‌سوی تولید متن مارکوف، حالا دنباله‌ای تصادفی از کلمه‌ها را از word_counter انتخاب می‌کنیم. اما اول درباره‌ی تصادفی بودن صحبت کنیم.

با ورودی‌های یکسان، بیشتر برنامه‌های رایانه‌ای قطعی (deterministic) هستند، یعنی هر بار خروجی یکسانی تولید می‌کنند. قطعی بودن معمولاً چیز خوبی است، چون انتظار داریم یک محاسبه‌ی یکسان نتیجه‌ی یکسانی بدهد. اما برای بعضی کاربردها می‌خواهیم رایانه غیرقابل‌پیش‌بینی باشد. بازی‌ها یک مثال‌اند، اما موارد دیگری هم هست.

غیرقطعی کردن واقعی یک برنامه دشوار است، اما راه‌هایی برای تقلید آن وجود دارد. یکی استفاده از الگوریتم‌هایی است که عددهای شبه‌تصادفی (pseudorandom) تولید می‌کنند. عددهای شبه‌تصادفی واقعاً تصادفی نیستند چون با یک محاسبه‌ی قطعی تولید می‌شوند، اما فقط با نگاه کردن به عددها تقریباً غیرممکن است آن‌ها را از عددهای تصادفی تشخیص داد.

ماژول random توابعی دارد که عددهای شبه‌تصادفی تولید می‌کنند – که از اینجا به بعد ساده «تصادفی» می‌نامم. می‌توانیم آن را این‌طور وارد کنیم.

import random
# this cell initializes the random number generator so it 
# generates the same sequence each time the notebook runs.

random.seed(4)

ماژول random تابعی به نام choice دارد که یک عنصر را از یک فهرست به‌صورت تصادفی انتخاب می‌کند، با احتمال یکسان برای انتخاب هر عنصر.

t = [1, 2, 3]
random.choice(t)
1

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

random.choice(t)
2

در درازمدت انتظار داریم هر عنصر را تقریباً به همان تعداد دفعات بگیریم.

اگر choice را با دیکشنری به کار ببرید، KeyError می‌گیرید.

random.choice(word_counter)
KeyError: 0

برای انتخاب یک کلید تصادفی، باید کلیدها را در یک فهرست بگذارید و بعد choice را فراخوانی کنید.

words = list(word_counter)
random.choice(words)
'place'

اگر دنباله‌ای تصادفی از کلمه‌ها تولید کنیم، معنای زیادی نمی‌دهد.

for i in range(6):
    word = random.choice(words)
    print(word, end=' ')
posture ill-contained written apocryphal nor busy 

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

اگر از مقدارهای word_counter به‌عنوان وزن استفاده کنیم، هر کلمه با احتمالی که به فراوانی‌اش بستگی دارد انتخاب می‌شود.

weights = word_counter.values()

ماژول random تابع دیگری به نام choices دارد که وزن‌ها را به‌عنوان آرگومان اختیاری می‌گیرد.

random.choices(words, weights=weights)
['the']

و آرگومان اختیاری دیگری، k، می‌گیرد که تعداد کلمه‌هایی را که باید انتخاب شود مشخص می‌کند.

random_words = random.choices(words, weights=weights, k=6)
random_words
['business', 'to', 'peep', 'the', 'what', 'he']

نتیجه فهرستی از رشته‌هاست که می‌توانیم آن‌ها را به چیزی شبیه‌تر به یک جمله به هم بچسبانیم.

' '.join(random_words)
'business to peep the what he'

اگر کلمه‌ها را به‌صورت تصادفی از کتاب انتخاب کنید، حسی از واژگان کتاب می‌گیرید، اما دنباله‌ای از کلمه‌های تصادفی به‌ندرت معنا می‌دهد چون بین کلمه‌های متوالی هیچ رابطه‌ای نیست. برای مثال، در یک جمله‌ی واقعی انتظار دارید بعد از حرف تعریفی مثل «the» یک صفت یا اسم بیاید، و احتمالاً نه فعل یا قید. پس قدم بعدی بررسی این رابطه‌ها بین کلمه‌هاست.

۱۲.۷. دوتایی‌ها (Bigrams)

به‌جای نگاه کردن به یک کلمه در هر بار، حالا به دنباله‌های دو کلمه‌ای نگاه می‌کنیم که دوتایی (bigram) نامیده می‌شوند. دنباله‌ی سه‌کلمه‌ای سه‌تایی (trigram) و دنباله‌ای با تعداد نامشخصی کلمه n-گرم (n-gram) نامیده می‌شود.

برنامه‌ای می‌نویسیم که همه‌ی دوتایی‌های کتاب و تعداد دفعاتی را که هر کدام ظاهر می‌شود پیدا کند. برای ذخیره‌ی نتیجه‌ها از دیکشنری‌ای استفاده می‌کنیم که

  • کلیدهایش چندتایی‌هایی از رشته‌ها هستند که دوتایی‌ها را نشان می‌دهند، و

  • مقدارهایش عددهای صحیحی هستند که فراوانی‌ها را نشان می‌دهند.

اسمش را bigram_counter می‌گذاریم.

bigram_counter = {}

تابع زیر فهرستی از دو رشته را به‌عنوان پارامتر می‌گیرد. اول چندتایی‌ای از دو رشته می‌سازد که می‌تواند به‌عنوان کلید دیکشنری به کار رود. بعد اگر کلید وجود نداشته باشد آن را به bigram_counter اضافه می‌کند، و اگر وجود داشته باشد فراوانی‌اش را افزایش می‌دهد.

def count_bigram(bigram):
    key = tuple(bigram)
    if key not in bigram_counter:
        bigram_counter[key] = 1
    else:
        bigram_counter[key] += 1

وقتی کتاب را پیمایش می‌کنیم، باید هر جفت کلمه‌ی متوالی را پیگیری کنیم. پس اگر دنباله‌ی “man is not truly one” را ببینیم، دوتایی‌های “man is”، “is not”، “not truly” و به همین ترتیب را اضافه می‌کنیم.

برای پیگیری این دوتایی‌ها از فهرستی به نام window (پنجره) استفاده می‌کنیم، چون مثل پنجره‌ای است که روی صفحه‌های کتاب می‌لغزد و در هر بار فقط دو کلمه را نشان می‌دهد. در ابتدا window خالی است.

window = []

از تابع زیر برای پردازش کلمه‌ها یکی‌یکی استفاده می‌کنیم.

def process_word(word):
    window.append(word)
    
    if len(window) == 2:
        count_bigram(window)
        window.pop(0)

اولین باری که این تابع فراخوانی می‌شود، کلمه‌ی داده‌شده را به window اضافه می‌کند. چون فقط یک کلمه در پنجره هست، هنوز دوتایی نداریم، پس تابع تمام می‌شود.

بار دوم که فراخوانی می‌شود – و هر بار بعد از آن – کلمه‌ی دومی را به window اضافه می‌کند. چون دو کلمه در پنجره هست، count_bigram را فراخوانی می‌کند تا پیگیری کند هر دوتایی چند بار ظاهر می‌شود. بعد از pop برای حذف کلمه‌ی اول از پنجره استفاده می‌کند.

برنامه‌ی زیر کلمه‌های کتاب را پیمایش می‌کند و یکی‌یکی پردازششان می‌کند.

for line in open(filename, encoding='utf-8'):
    for word in split_line(line):
        word = clean_word(word)
        process_word(word)

نتیجه دیکشنری‌ای است که هر دوتایی را به تعداد دفعاتی که ظاهر می‌شود نگاشت می‌کند. می‌توانیم از print_most_common برای دیدن رایج‌ترین دوتایی‌ها استفاده کنیم.

print_most_common(bigram_counter)
178	('of', 'the')
139	('in', 'the')
94	('it', 'was')
80	('and', 'the')
73	('to', 'the')

با نگاه کردن به این نتیجه‌ها می‌توانیم حسی بگیریم که کدام جفت کلمه‌ها بیشتر احتمال دارد با هم ظاهر شوند. همچنین می‌توانیم از نتیجه‌ها برای تولید متن تصادفی استفاده کنیم، مثل این.

random.seed(0)
bigrams = list(bigram_counter)
weights = bigram_counter.values()
random_bigrams = random.choices(bigrams, weights=weights, k=6)

bigrams فهرستی از دوتایی‌هایی است که در کتاب ظاهر می‌شوند. weights فهرست فراوانی‌های آن‌هاست، پس random_bigrams نمونه‌ای است که احتمال انتخاب هر دوتایی در آن متناسب با فراوانی‌اش است.

این نتیجه‌هاست.

for pair in random_bigrams:
    print(' '.join(pair), end=' ')
to suggest this preface to detain fact is above all the laboratory 

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

۱۲.۸. تحلیل مارکوف

با تحلیل متن زنجیره‌ی مارکوف (Markov chain) می‌توانیم بهتر عمل کنیم؛ این روش برای هر کلمه‌ی متن، فهرست کلمه‌هایی را که بعد از آن می‌آیند حساب می‌کند. به‌عنوان مثال، این متن کوتاه (که خودم نوشته‌ام و جایگزین ترانه‌ی کتاب اصلی شده) را تحلیل می‌کنیم:

song = """
The cat sat on the mat,
the cat saw a rat.
The rat ran off the mat,
and the cat sat on that.
"""

برای ذخیره‌ی نتیجه‌ها از دیکشنری‌ای استفاده می‌کنیم که هر کلمه را به فهرست کلمه‌هایی که بعد از آن می‌آیند نگاشت می‌کند.

successor_map = {}

به‌عنوان مثال، با دو کلمه‌ی اول متن شروع می‌کنیم.

first = 'the'
second = 'cat'

اگر کلمه‌ی اول در successor_map نباشد، باید موردی جدید اضافه کنیم که کلمه‌ی اول را به فهرستی شامل کلمه‌ی دوم نگاشت می‌کند.

successor_map[first] = [second]
successor_map
{'the': ['cat']}

اگر کلمه‌ی اول از قبل در دیکشنری باشد، می‌توانیم آن را جست‌وجو کنیم تا فهرست جانشین‌هایی (successors) را که تا الان دیده‌ایم بگیریم و جانشین جدید را اضافه کنیم.

first = 'the'
second = 'mat'

successor_map[first].append(second)
successor_map
{'the': ['cat', 'mat']}

تابع زیر این مراحل را کپسوله می‌کند.

def add_bigram(bigram):
    first, second = bigram
    
    if first not in successor_map:
        successor_map[first] = [second]
    else:
        successor_map[first].append(second)

اگر یک دوتایی بیش از یک بار ظاهر شود، کلمه‌ی دوم بیش از یک بار به فهرست اضافه می‌شود. به این شکل successor_map پیگیری می‌کند هر جانشین چند بار ظاهر می‌شود.

همان‌طور که در بخش قبل کردیم، از فهرستی به نام window برای ذخیره‌ی جفت‌های کلمه‌های متوالی استفاده می‌کنیم. و از تابع زیر برای پردازش کلمه‌ها یکی‌یکی.

def process_word_bigram(word):
    window.append(word)
    
    if len(window) == 2:
        add_bigram(window)
        window.pop(0)

این‌طور از آن برای پردازش کلمه‌های متن استفاده می‌کنیم.

successor_map = {}
window = []

for word in song.split():
    word = clean_word(word)
    process_word_bigram(word)

و این نتیجه‌هاست.

successor_map
{'the': ['cat', 'mat', 'cat', 'rat', 'mat', 'cat'],
 'cat': ['sat', 'saw', 'sat'],
 'sat': ['on', 'on'],
 'on': ['the', 'that'],
 'mat': ['the', 'and'],
 'saw': ['a'],
 'a': ['rat'],
 'rat': ['the', 'ran'],
 'ran': ['off'],
 'off': ['the'],
 'and': ['the']}

(در نسخه‌ی اصلی کتاب این بخش با متن یک ترانه نمایش داده شده و توضیح آن درباره‌ی کلمه‌هایی مثل 'half' و 'a' است. در متن جایگزین من، کلمه‌ی 'the' می‌تواند با 'cat'، 'mat' یا 'rat' دنبال شود – و 'cat' سه بار پس از 'the' آمده است. کلمه‌ی 'cat' می‌تواند با 'sat' یا 'saw' دنبال شود. بیشتر کلمه‌های دیگر فقط یک جانشین دارند.)

حالا کتاب را تحلیل می‌کنیم.

successor_map = {}
window = []

for line in open(filename, encoding='utf-8'):
    for word in split_line(line):
        word = clean_word(word)
        process_word_bigram(word)

می‌توانیم هر کلمه‌ای را جست‌وجو کنیم و کلمه‌هایی را که می‌توانند بعد از آن بیایند پیدا کنیم.

# I used this cell to find a predecessor with a good number of possible successors
# and at least one repeated word.

def has_duplicates(t):
    return len(set(t)) < len(t)

for key, value in successor_map.items():
    if len(value) == 7 and has_duplicates(value):
        print(key, value)

(این سلول فقط برای پیدا کردن یک نمونه‌ی خوب بود؛ خروجی‌اش فهرست بلندی از کلمه‌هاست و اینجا نمی‌آورمش.)

successor_map['going']
['east', 'in', 'to', 'to', 'up', 'to', 'of']

در این فهرست جانشین‌ها دقت کنید که کلمه‌ی 'to' سه بار ظاهر می‌شود – جانشین‌های دیگر فقط یک بار آمده‌اند.

۱۲.۹. تولید متن

می‌توانیم از نتیجه‌های بخش قبل برای تولید متن جدیدی استفاده کنیم که همان رابطه‌های بین کلمه‌های متوالی متن اصلی را دارد. این‌طور کار می‌کند:

  • با هر کلمه‌ای که در متن هست شروع می‌کنیم، جانشین‌های ممکنش را جست‌وجو می‌کنیم و یکی را تصادفی انتخاب می‌کنیم.

  • بعد، با کلمه‌ی انتخاب‌شده، جانشین‌های ممکنش را جست‌وجو می‌کنیم و یکی را تصادفی انتخاب می‌کنیم.

می‌توانیم این فرایند را تکرار کنیم تا هر تعداد کلمه که بخواهیم تولید کنیم. به‌عنوان مثال، با کلمه‌ی 'although' شروع می‌کنیم. این کلمه‌هایی هستند که می‌توانند بعد از آن بیایند.

word = 'although'
successors = successor_map[word]
successors
['i', 'a', 'it', 'the', 'we', 'they', 'i']
# this cell initializes the random number generator so it 
# starts at the same point in the sequence each time this
# notebook runs.

random.seed(2)

می‌توانیم از choice برای انتخاب از فهرست با احتمال یکسان استفاده کنیم.

word = random.choice(successors)
word
'i'

اگر یک کلمه بیش از یک بار در فهرست ظاهر شود، احتمال انتخابش بیشتر است.

با تکرار این مراحل، می‌توانیم از حلقه‌ی زیر برای تولید دنباله‌ای بلندتر استفاده کنیم.

for i in range(10):
    successors = successor_map[word]
    word = random.choice(successors)
    print(word, end=' ')
continue to hesitate and swallowed the smile withered from that 

نتیجه بیشتر شبیه یک جمله‌ی واقعی به نظر می‌رسد، اما هنوز معنای زیادی نمی‌دهد.

می‌توانیم با استفاده از بیش از یک کلمه به‌عنوان کلید در successor_map بهتر عمل کنیم. برای مثال، می‌توانیم دیکشنری‌ای بسازیم که هر دوتایی – یا سه‌تایی – را به فهرست کلمه‌هایی که بعد می‌آیند نگاشت می‌کند. در تمرین‌ها فرصت دارید این تحلیل را پیاده‌سازی کنید و ببینید نتیجه‌ها چه شکلی هستند.

۱۲.۱۰. اشکال‌زدایی

در این نقطه برنامه‌های اساسی‌تری می‌نویسیم و شاید متوجه شوید که زمان بیشتری را صرف اشکال‌زدایی می‌کنید. اگر روی یک باگ دشوار گیر کرده‌اید، این‌ها چند چیز برای امتحان کردن است:

  • خواندن (Reading): کدتان را بررسی کنید، برای خودتان بلند بخوانید و ببینید همان چیزی را می‌گوید که قصد داشتید بگویید.

  • اجرا کردن (Running): با ایجاد تغییر و اجرای نسخه‌های مختلف آزمایش کنید. اغلب اگر چیز درست را در جای درست برنامه نمایش دهید، مشکل روشن می‌شود، اما گاهی باید داربست بسازید.

  • تأمل (Ruminating): کمی وقت بگذارید و فکر کنید! چه نوع خطایی است: نحوی، زمان اجرا یا معنایی؟ از پیام‌های خطا یا خروجی برنامه چه اطلاعاتی می‌توانید بگیرید؟ چه نوع خطایی می‌تواند مشکلی را که می‌بینید ایجاد کند؟ آخرین چیزی که تغییر دادید، قبل از ظاهر شدن مشکل، چه بود؟

  • توضیح به اردک لاستیکی (Rubberducking): اگر مشکل را برای کسی دیگر توضیح دهید، گاهی جواب را قبل از تمام کردن پرسش پیدا می‌کنید. اغلب نیازی به آن شخص ندارید؛ می‌توانید فقط با یک اردک لاستیکی صحبت کنید. و این ریشه‌ی راهبرد معروف اشکال‌زدایی با اردک لاستیکی (rubber duck debugging) است. من این را از خودم درنمی‌آورم – نگاه کنید به https://en.wikipedia.org/wiki/Rubber_duck_debugging.

  • عقب‌نشینی (Retreating): در یک نقطه، بهترین کار عقب رفتن است – برگرداندن تغییرهای اخیر – تا به برنامه‌ای برسید که کار می‌کند. بعد می‌توانید دوباره شروع به ساختن کنید.

  • استراحت (Resting): اگر به مغزتان استراحت بدهید، گاهی خودش مشکل را برایتان پیدا می‌کند.

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

برای مثال، خواندن کد وقتی کار می‌کند که مشکل یک اشتباه تایپی باشد، اما وقتی مشکل سوءتفاهم مفهومی باشد کار نمی‌کند. اگر نمی‌فهمید برنامه‌تان چه می‌کند، می‌توانید ۱۰۰ بار آن را بخوانید و هرگز خطا را نبینید، چون خطا در ذهن خودتان است.

انجام آزمایش‌ها می‌تواند کار کند، به‌ویژه اگر آزمون‌های کوچک و ساده اجرا کنید. اما اگر بدون فکر کردن یا خواندن کدتان آزمایش کنید، ممکن است مدت زیادی طول بکشد تا بفهمید چه اتفاقی می‌افتد.

باید وقت بگذارید و فکر کنید. اشکال‌زدایی مثل یک علم تجربی است. باید دست‌کم یک فرضیه درباره‌ی مشکل داشته باشید. اگر دو یا چند احتمال وجود دارد، سعی کنید آزمونی بیابید که یکی از آن‌ها را حذف کند.

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

برنامه‌نویسان مبتدی اغلب برای عقب‌نشینی بی‌میل‌اند چون تحمل پاک کردن یک خط کد را (حتی اگر غلط باشد) ندارند. اگر حالتان را بهتر می‌کند، قبل از شروع به کوچک کردن، برنامه‌تان را در فایل دیگری کپی کنید. بعد می‌توانید تکه‌ها را یکی‌یکی برگردانید.

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

۱۲.۱۱. واژه‌نامه

مقدار پیش‌فرض (default value): مقداری که اگر آرگومانی داده نشود به یک پارامتر نسبت داده می‌شود.

بازنویسی (override): جایگزین کردن مقدار پیش‌فرض با یک آرگومان.

قطعی (deterministic): برنامه‌ی قطعی با ورودی‌های یکسان هر بار که اجرا شود همان کار را انجام می‌دهد.

شبه‌تصادفی (pseudorandom): دنباله‌ی شبه‌تصادفی از عددها تصادفی به نظر می‌رسد، اما با یک برنامه‌ی قطعی تولید می‌شود.

دوتایی (bigram): دنباله‌ای از دو عنصر، اغلب کلمه.

سه‌تایی (trigram): دنباله‌ای از سه عنصر.

n-گرم (n-gram): دنباله‌ای از تعداد نامشخصی عنصر.

اشکال‌زدایی با اردک لاستیکی (rubber duck debugging): روشی برای اشکال‌زدایی با توضیح دادن مشکل با صدای بلند برای یک شیء بی‌جان.

۱۲.۱۲. تمرین‌ها

# This cell tells Jupyter to provide detailed debugging information
# when a runtime error occurs. Run it before working on the exercises.

%xmode Verbose

۱۲.۱۲.۱. از یک دستیار مجازی بپرسید

در add_bigram، دستور if بسته به اینکه کلید از قبل در دیکشنری هست یا نه، فهرست جدیدی می‌سازد یا عنصری به فهرست موجود اضافه می‌کند.

def add_bigram(bigram):
    first, second = bigram
    
    if first not in successor_map:
        successor_map[first] = [second]
    else:
        successor_map[first].append(second)

دیکشنری‌ها متدی به نام setdefault دارند که می‌توانیم برای انجام همین کار به‌صورت مختصرتر به کار ببریم. از یک دستیار مجازی بپرسید چطور کار می‌کند، یا add_bigram را در یک دستیار مجازی کپی کنید و بپرسید «Can you rewrite this using setdefault?» (می‌توانی این را با setdefault بازنویسی کنی؟)

در این فصل تحلیل و تولید متن زنجیره‌ی مارکوف را پیاده‌سازی کردیم. اگر کنجکاوید، می‌توانید از یک دستیار مجازی اطلاعات بیشتری درباره‌ی این موضوع بپرسید. یکی از چیزهایی که ممکن است یاد بگیرید این است که دستیارهای مجازی از الگوریتم‌هایی استفاده می‌کنند که از جهات زیادی شبیه‌اند – اما از جهات مهمی هم فرق دارند. از یک دستیار مجازی بپرسید: «What are the differences between large language models like GPT and Markov chain text analysis?» (تفاوت‌های مدل‌های زبانی بزرگ مثل GPT با تحلیل متن زنجیره‌ی مارکوف چیست؟)

۱۲.۱۲.۲. تمرین

تابعی بنویسید که تعداد دفعاتی را که هر سه‌تایی (دنباله‌ی سه‌کلمه‌ای) ظاهر می‌شود بشمارد. اگر تابع‌تان را با متن Dr. Jekyll and Mr. Hyde آزمایش کنید، باید ببینید که رایج‌ترین سه‌تایی “said the lawyer” است.

راهنمایی: تابعی به نام count_trigram بنویسید که شبیه count_bigram باشد. بعد تابعی به نام process_word_trigram بنویسید که شبیه process_word_bigram باشد.

# Solution goes here
# Solution goes here

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

trigram_counter = {}
window = []

for line in open(filename, encoding='utf-8'):
    for word in split_line(line):
        word = clean_word(word)
        process_word_trigram(word)

بعد با print_most_common رایج‌ترین سه‌تایی‌های کتاب را پیدا کنید.

print_most_common(trigram_counter)

۱۲.۱۲.۳. تمرین

حالا تحلیل متن زنجیره‌ی مارکوف را با نگاشت از هر دوتایی به فهرستی از جانشین‌های ممکن پیاده‌سازی می‌کنیم.

با شروع از add_bigram، تابعی به نام add_trigram بنویسید که فهرستی از سه کلمه بگیرد و یک مورد را در successor_map اضافه یا به‌روزرسانی کند، با استفاده از دو کلمه‌ی اول به‌عنوان کلید و کلمه‌ی سوم به‌عنوان یک جانشین ممکن.

# Solution goes here

این نسخه‌ای از process_word_trigram است که add_trigram را فراخوانی می‌کند.

def process_word_trigram(word):
    window.append(word)
    
    if len(window) == 3:
        add_trigram(window)
        window.pop(0)

می‌توانید از حلقه‌ی زیر برای آزمودن تابع‌تان با متن کوتاه همین فصل (متغیر song) استفاده کنید. (در کتاب اصلی، این حلقه با متن ترانه‌ی کتاب آزمایش می‌شود.)

successor_map = {}
window = []

for string in song.split():
    word = string.strip(punctuation).lower()
    process_word_trigram(word)

اگر تابع‌تان درست کار کند، برای هر کلید دوتایی – مثلاً ('the', 'cat') – باید فهرستی از جانشین‌هایی که بعد از آن دوتایی در متن آمده‌اند به دست آورید. (در متن کتاب اصلی هر دوتایی فقط یک بار ظاهر می‌شد و همه‌ی مقدارها یک عنصر داشتند؛ در متن جایگزین من لزوماً این‌طور نیست، پس برای بعضی کلیدها بیش از یک جانشین می‌بینید.)

successor_map

می‌توانید از حلقه‌ی زیر برای آزمودن تابع‌تان با کلمه‌های کتاب استفاده کنید.

successor_map = {}
window = []

for line in open(filename, encoding='utf-8'):
    for word in split_line(line):
        word = clean_word(word)
        process_word_trigram(word)

در تمرین بعدی، از نتیجه‌ها برای تولید متن تصادفی جدید استفاده می‌کنید.

۱۲.۱۲.۴. تمرین

برای این تمرین، فرض می‌کنیم successor_map دیکشنری‌ای است که هر دوتایی را به فهرست کلمه‌هایی که بعد از آن می‌آیند نگاشت می‌کند.

# this cell initializes the random number generator so it 
# starts at the same point in the sequence each time this
# notebook runs.

random.seed(3)

برای تولید متن تصادفی، با انتخاب یک کلید تصادفی از successor_map شروع می‌کنیم.

successors = list(successor_map)
bigram = random.choice(successors)
bigram

حالا حلقه‌ای بنویسید که ۵۰ کلمه‌ی بعدی را با این مراحل تولید کند:

  1. در successor_map، فهرست کلمه‌هایی را که می‌توانند بعد از bigram بیایند جست‌وجو کنید.

  2. یکی از آن‌ها را تصادفی انتخاب و چاپ کنید.

  3. برای تکرار بعدی، دوتایی جدیدی بسازید که کلمه‌ی دوم bigram و جانشین انتخاب‌شده را دارد.

برای مثال، اگر با دوتایی ('doubted', 'if') شروع کنیم و 'from' را به‌عنوان جانشینش انتخاب کنیم، دوتایی بعدی ('if', 'from') است.

# Solution goes here

اگر همه‌چیز درست کار کند، باید ببینید که متن تولیدشده از نظر سبک به‌طور قابل‌تشخیصی شبیه متن اصلی است و بعضی عبارت‌ها معنا می‌دهند، اما ممکن است متن از موضوعی به موضوع دیگر برود.

به‌عنوان تمرین امتیازی، راه‌حل‌تان را برای دو تمرین آخر طوری تغییر دهید که از سه‌تایی‌ها به‌عنوان کلید در successor_map استفاده کند، و ببینید چه اثری بر نتیجه‌ها دارد.