সরাসরি পর্বে যান
পর্ব ৪৪ · ৭৭-এর মধ্যে · মডিউল ৮

Topic modeling — LDA আর NMF দিয়ে বাংলা রিভিউয়ের বিষয় খোঁজা

Topic Modeling — Bag of Words, LSA, Latent Dirichlet Allocation and NMF on Bangla Product Reviews, Choosing the Number of Topics, and Topics as Features

Taught in Bangla, this lesson finds topics in fictional Bangla product reviews. It fixes the default tokenizer that breaks Bangla vowel signs, explains the LDA generative story, reads document-topic vectors, compares LDA, NMF and LSA, chooses the number of topics with held-out perplexity and hand-written UMass coherence, and tests topic proportions as classifier features in real runs.

এই পর্বে যা শিখবেন

  • রিভিউকে bag of words আর document-term matrix-এ আনা, বাংলা কার-চিহ্ন না হারিয়ে
  • LSA থেকে pLSA হয়ে LDA: Dirichlet prior α আর η, θ আর φ, আর একটি রিভিউ তৈরির পুরো গল্প
  • scikit-learn-এ LDA, NMF আর TruncatedSVD চালিয়ে শীর্ষ শব্দ পড়া, আর আসল বিষয়ের সঙ্গে মেলানো
  • Document–topic vector পড়া: কোন শব্দ কোন topic-এ গেল আর কেন
  • Topic-এর সংখ্যা বাছাই: held-out perplexity, নিজে লেখা UMass coherence, মানুষের বিচার, আর topic-অনুপাতকে feature হিসেবে ব্যবহারের সীমা

AI-এর ভিত্তি কোর্সের পর্ব ৬৭-এ বাংলা লেখা পরিষ্কার করা, token-এ ভাঙা আর TF-IDF দেখেছেন; পর্ব ৬৫-এ label ছাড়া গঠন খোঁজার প্রথম পদ্ধতি (k-means, PCA). এই কোর্সের পর্ব ০৯-এ TF-IDF-এর truncated SVD (LSA) এসেছে, আর পর্ব ১৮-এ prior, MAP আর conjugate prior. এই পর্ব প্রশ্ন তোলে: বাজারঘরের হাজার হাজার বাংলা রিভিউ কেউ পড়ে শেষ করতে পারে না; label ছাড়া কীভাবে জানবেন ক্রেতারা কোন কোন বিষয় নিয়ে লেখেন, আর কোন রিভিউয়ে কোন বিষয় কতখানি? চালানো যায় এমন চারটি ব্লক পাতাতেই চলে (ব্রাউজারের Python, scikit-learn 1.8.0); প্রথমবার scikit-learn নামতে প্রায় ১০ সেকেন্ড লাগে। সব ব্লক পাতার একটি ছোট module bazar_reviews.py import করে।

১ · রিভিউয়ের স্তূপ থেকে বিষয় — bag of words আর document-term matrix

ঈদের পরের সপ্তাহে বাজারঘরের কল সেন্টারে অভিযোগের খাতা ভরে যায়। একজন অভিজ্ঞ সুপারভাইজার খাতা উল্টাতে উল্টাতে বলেন, “অর্ধেক অভিযোগ কুরিয়ার নিয়ে, বাকিগুলোর বড় অংশ ছেঁড়া প্যাকেট আর দাম।” কেউ তাঁকে বিষয়ের তালিকা দেয়নি; তিনি দেখেছেন কোন শব্দ বারবার একসঙ্গে আসে। আর একটি অভিযোগে দুই বিষয় থাকলে তিনি সেটিকে দুই ভাগেই গোনেন। Topic model ঠিক এই কাজটি সংখ্যা দিয়ে করে।

Topic model-এর দুটি মৌলিক ধারণা। প্রথমত, একটি topic হলো শব্দের ওপর একটি সম্ভাবনা-বণ্টন: “ডেলিভারি” topic-এ দেরিতে, কুরিয়ার, রাইডার শব্দের সম্ভাবনা বেশি, কাপড় বা সেলাই-এর প্রায় শূন্য। দ্বিতীয়ত, একটি রিভিউ কয়েকটি topic-এর মিশ্রণ, যেমন ৭০% ডেলিভারি আর ৩০% দাম। এখানেই clustering থেকে পার্থক্য: k-means প্রতিটি রিভিউকে ঠিক একটি দলে দেয় (AI-এর ভিত্তি কোর্সের পর্ব ৬৫), পর্ব ৩৯ (Gaussian mixture আর EM algorithm — নরম clustering) একটি বিন্দুকে কয়েকটি দলে সম্ভাবনা দেয়, আর topic model আরও এক ধাপ এগিয়ে একটি রিভিউয়ের প্রতিটি শব্দকে আলাদা topic-এ দিতে পারে।

শুরু bag of words দিয়ে: শব্দের ক্রম ফেলে দিয়ে শুধু কোন শব্দ কতবার আছে। D-টি রিভিউ আর V-টি আলাদা শব্দ (vocabulary) থাকলে পাওয়া যায় একটি D × V document-term matrix X, যার ঘর Xdw = রিভিউ d-তে শব্দ w কতবার। ছোট রিভিউয়ে প্রায় সব ঘর শূন্য, তাই X একটি sparse matrix. LDA গণনা চায় (CountVectorizer), NMF আর LSA সাধারণত TF-IDF ওজন নেয়।

বাংলায় প্রথম ফাঁদ: tokenization. scikit-learn-এর default token_pattern হলো (?u)\b\w\w+\b, অর্থাৎ পরপর অন্তত দুটি “word character”. Python-এর re-তে কার-চিহ্ন (া, ি, ে) আর হসন্ত Unicode-এর “mark” শ্রেণির, \w নয়। ফলে “রাইডার” ভেঙে যায় আর মাঝের দুই-অক্ষরের টুকরো “ইড” থাকে। সমাধান: বাংলা Unicode block (U+0980 থেকে U+09FF) পুরোটা ধরে এমন pattern, [\u0980-\u09FF]+. দাঁড়ি (।) এই block-এর বাইরে (U+0964), তাই বাক্যের শেষে ঠিকমতো কাটে। দুটি সতর্কতা: “র‍্যাব”-এর মতো শব্দে zero-width joiner (U+200D) থাকে, যা এই pattern-এ শব্দ ভাঙে, আর ইংরেজি বা Banglish শব্দ এতে পুরো বাদ পড়ে। আসল ডেটায় আগে AI-এর ভিত্তি কোর্সের পর্ব ৬৭-এর মতো Unicode normalization আর নিজের tokenizer দরকার হতে পারে।

Stopword: “আর”, “কিন্তু”, “খুব”-এর মতো শব্দ সব topic-এ আসে আর শীর্ষ শব্দের তালিকা দখল করে। এখানে একটি ছোট তালিকা দিয়ে বাদ দেওয়া হয়েছে। তবে “না”, “নেই”, “পাইনি” বাদ দেবেন না: অভিযোগের অর্থ এগুলোতেই।

এই পর্বের ডেটা: বাজারঘরের ৬০০টি কাল্পনিক ছোট রিভিউ, seed দিয়ে বানানো। পাঁচটি বিষয় (delivery, packaging, price, quality, service), প্রতিটির কয়েকটি ভালো আর খারাপ “টুকরো”। প্রতিটি রিভিউ Dirichlet(0.4) থেকে একটি বিষয়-মিশ্রণ পায়, তারপর ২–৪টি টুকরো সেই মিশ্রণ থেকে আসে, মাঝে “আর/কিন্তু/তবে”। ভালো টুকরো রেটিং বাড়ায়, খারাপটি কমায়। Generator নিজেই LDA-র গল্পের সরল রূপ (অংশ ৩), তাই আসল উত্তর আমাদের জানা, যা বাস্তবে কখনো থাকে না। “ডেলিভারি চার্জ বেশি” (দাম), “টাকা ফেরত পাইনি” (সেবা)-র মতো কিছু শব্দ ইচ্ছে করে দুই বিষয়ে রাখা।

# bazar_reviews.py — বাজারঘরের কাল্পনিক বাংলা রিভিউ (seed দিয়ে বানানো)
import numpy as np

TOPICS = ["delivery", "packaging", "price", "quality", "service"]
CHUNKS = {   # প্রতিটি বিষয়ের (ভালো টুকরো, খারাপ টুকরো)
    "delivery": (["ডেলিভারি দ্রুত", "রাইডার ভদ্র", "সময়মতো পৌঁছেছে",
                  "পরদিনই হাতে পেয়েছি", "কুরিয়ার দ্রুত এসেছে"],
                 ["ডেলিভারি দেরিতে", "রাইডার ফোন ধরেননি", "তিন দিন দেরি",
                  "কুরিয়ার দেরিতে এসেছে", "ঠিকানা খুঁজে পায়নি"]),
    # … packaging, price, quality, service একইভাবে (পাতার module-এ পুরোটা)
}
P_BAD = [0.6, 0.5, 0.4, 0.3, 0.55]     # কোন বিষয়ে অভিযোগ কত বেশি
STOP = ["আর", "কিন্তু", "তবে", "খুব", "একদম", "এবার", "আমার", "অর্ডারে"]
TOKEN = r"[\u0980-\u09FF]+"            # বাংলা block-এর অক্ষর, কার-চিহ্নসহ

def make_reviews(n=600, alpha=0.4, seed=44):
    """ফেরত: লেখা, আসল বিষয়ের অনুপাত (n × 5), রেটিং ১–৫"""
    rng = np.random.default_rng(seed)
    texts, props, ratings = [], np.zeros((n, 5)), []
    for i in range(n):
        theta = rng.dirichlet(np.full(5, alpha))   # এই রিভিউয়ের বিষয়-মিশ্রণ
        words, score = [], 0
        if rng.random() < 0.3:
            words.append(str(rng.choice(["এবার", "আমার অর্ডারে"])))
        for j in range(int(rng.integers(2, 5))):   # ২–৪টি টুকরো
            k = rng.choice(5, p=theta)              # টুকরোর বিষয়
            bad = rng.random() < P_BAD[k]
            pool = CHUNKS[TOPICS[k]][int(bad)]
            if j > 0:
                words.append(str(rng.choice(["আর", "কিন্তু", "তবে"])))
            if rng.random() < 0.2:
                words.append(str(rng.choice(["খুব", "একদম"])))
            words.append(pool[rng.integers(len(pool))])
            props[i, k] += 1
            score += -1 if bad else 1
        texts.append(" ".join(words))
        props[i] /= props[i].sum()
        r = 3.2 + 0.8 * score + rng.normal(0, 0.6)
        ratings.append(int(np.clip(np.rint(r), 1, 5)))
    return texts, props, np.array(ratings)

২ · LSA থেকে pLSA — কেন একটি সম্ভাবনার গল্প দরকার

প্রথম topic model আসলে পর্ব ০৯-এর truncated SVD: LSA (Deerwester ও সহকর্মীরা, ১৯৯০) X ≈ UkΣkVkᵀ লেখে, আর Vkᵀ-এর প্রতিটি সারিকে একটি “topic” হিসেবে পড়ে। একসঙ্গে আসা শব্দ (দেরিতে, কুরিয়ার) একই দিকে ভারী হয়, তাই একই অর্থের ভিন্ন শব্দও কাছাকাছি আসে। কিন্তু তিনটি অসুবিধা আছে। দিকগুলো পরস্পর লম্ব হতে বাধ্য, অথচ বাস্তবের বিষয় তা নয়। ওজন ঋণাত্মক হতে পারে, আর “−০.৩ × ফেরত” মানে কী, তা বলা কঠিন। আর TruncatedSVD গড় বাদ দেয় না, তাই প্রথম দিক প্রায়ই শুধু “গড় রিভিউ” (পর্ব ০৯-এর অংশ ৮)। অংশ ৬-এ এগুলো চোখে দেখবেন।

Thomas Hofmann ১৯৯৯ সালে একটি সম্ভাবনার রূপ দেন, pLSA:

p(w | d) = ∑k p(k | d) · p(w | k)

অর্থাৎ রিভিউ d-তে শব্দ w আসার সম্ভাবনা = প্রতিটি topic k-এর ভাগ × সেই topic-এ w-এর সম্ভাবনা, সব topic-এ যোগ। এখন সব সংখ্যা অঋণাত্মক আর যোগফল ১, তাই পড়া সহজ, আর পর্ব ১৮-এর maximum likelihood দিয়ে (EM দিয়ে) শেখা যায়। কিন্তু pLSA-তে প্রতিটি রিভিউয়ের p(k | d) একটি আলাদা parameter: রিভিউ বাড়লে parameter-ও বাড়ে, overfit সহজ, আর নতুন রিভিউয়ের জন্য p(k | d) বলার কোনো নিয়ম নেই। ঘাটতি একটাই: “রিভিউয়ের মিশ্রণ কোথা থেকে আসে” তার কোনো গল্প নেই। LDA ঠিক এই ফাঁকটি একটি prior দিয়ে ভরায়।

৩ · LDA-র generative গল্প — Dirichlet, θ আর φ

David Blei, Andrew Ng আর Michael Jordan ২০০৩ সালে Journal of Machine Learning Research-এ Latent Dirichlet Allocation প্রকাশ করেন (এর আগের সংস্করণ NIPS ২০০১-এ)। নামের তিন শব্দ: latent কারণ topic চোখে দেখা যায় না (latent variable), Dirichlet কারণ মিশ্রণগুলো Dirichlet বণ্টন থেকে আসে, allocation কারণ প্রতিটি শব্দ একটি topic-এ বরাদ্দ হয়। নামটি linear discriminant analysis-এর সংক্ষেপের সঙ্গে মিলে যায়; প্রসঙ্গ দেখে পড়ুন।

Dirichlet distribution. K-টি অঋণাত্মক সংখ্যা যাদের যোগফল ১, যেমন (০.৭, ০.২৫, ০.০৫), তাদের ওপর একটি বণ্টন। এই সব vector মিলে probability simplex, তিন topic-এ একটি ত্রিভুজ। Parameter α = (α1, …, αK)-কে বলে concentration parameter; গড় E[θk] = αk / ∑αj. সব αk সমান হলে গড় সমান ভাগ, কিন্তু আকৃতি নির্ভর করে মানের ওপর: α < ১ হলে নমুনা ত্রিভুজের কোণে জমে (প্রতিটি রিভিউ এক-দুটি বিষয়ে), α = ১ হলে ত্রিভুজের সব জায়গা সমান, α > ১ হলে মাঝখানে (সব বিষয় একটু একটু)। K = ২ হলে Dirichlet হয়ে যায় Beta distribution, আর পর্ব ১৮-এর Beta–binomial conjugate prior এখানে Dirichlet–multinomial হয়: প্রতিটি দেখা শব্দ prior-এর গণনায় ১ যোগ করে। ছোট রিভিউয়ের জন্য α < ১ স্বাভাবিক পছন্দ, কারণ তিন লাইনের রিভিউ সাধারণত পাঁচ বিষয়ে ছড়ানো থাকে না।

গল্পটি (K টি topic, V শব্দের vocabulary):

  1. প্রতিটি topic k-এর জন্য একটি শব্দ-বণ্টন আঁকা: φk ~ Dirichlet(η). এটি topic–word distribution; η ছোট হলে প্রতিটি topic অল্প কয়েকটি শব্দে ভারী।
  2. প্রতিটি রিভিউ d-এর জন্য একটি মিশ্রণ: θd ~ Dirichlet(α), অর্থাৎ document–topic distribution.
  3. রিভিউয়ের প্রতিটি শব্দের জায়গা n-এর জন্য: প্রথমে topic zdn ~ Categorical(θd), তারপর শব্দ wdn ~ Categorical(φzdn).

Categorical মানে ছক্কা চালার মতো: সম্ভাবনার তালিকা দিয়ে একটি ফল বাছা। z মাঝখান থেকে যোগ করে দিলে আবার pLSA-র সূত্রই আসে, p(w | d) = ∑k θdk φkw, শুধু এখন θ আর φ দুটিরই prior আছে। Matrix-এর ভাষায়: প্রতিটি সারিকে মোট শব্দ দিয়ে ভাগ করা X ≈ Θ Φ, যেখানে Θ (D × K) আর Φ (K × V)-এর প্রতিটি সারি একটি সম্ভাবনা-vector. এটি অঋণাত্মক matrix factorization-এর একটি সম্ভাবনাভিত্তিক রূপ; অংশ ৬-এর NMF-এর সঙ্গে তুলনা করার সময় মনে রাখবেন।

LDA-র plate diagram আর matrix-এর ছবি

Plate notation of LDA and its matrix view

LDA plate diagram and the matrix view X ≈ Θ Φ D reviews N words K topics α θ z w φ η shaded = observed others = hidden X: D×V ≈ Θ × Φ: K×V Θ: D×K rows sum to 1
ছবি ৪৪.১ — প্রতিটি বৃত্ত একটি চলক, তীর মানে “এর ওপর নির্ভর করে আঁকা”। আয়তক্ষেত্র (plate) মানে ভেতরের অংশ লেখা সংখ্যক বার পুনরাবৃত্তি: প্রতিটি রিভিউয়ে একটি θ, প্রতিটি শব্দে একটি z আর w, প্রতিটি topic-এ একটি φ. শুধু w দেখা যায় (ধূসর), বাকি সব লুকানো। নিচে একই কথা matrix-এ: সারি-স্বাভাবিক X ≈ Θ (রিভিউ × topic) × Φ (topic × শব্দ)।

নিচের stepper একটি ছোট খেলনা উদাহরণে (তিন topic, ছয় শব্দ, সংখ্যাগুলো হাতে বাছা) একটি রিভিউ তৈরি করে, তারপর তার সম্ভাবনা হিসাব করে।

θ ~ Dir(α), z ~ θ, w ~ φzα = 0.4 · K = 3

            
  1. Topic আগে থেকেই আছে। প্রতিটি topic একটি শব্দ-বণ্টন: delivery-তে দেরিতে ০.৪০, কুরিয়ার ০.৩০; price-এ দাম ০.৪৫, বেশি ০.৪০; quality-তে কাপড় ০.৭০. প্রতিটি কলামের যোগফল ১. বাস্তবে এগুলো Dirichlet(η) থেকে আসে আর সব রিভিউ এগুলো ভাগ করে নেয়।
  2. রিভিউ তার মিশ্রণ পায়। Dirichlet(0.4, 0.4, 0.4) থেকে আঁকা θ = (০.৭০, ০.২৫, ০.০৫): এই রিভিউ মূলত ডেলিভারি নিয়ে, কিছুটা দাম। α ১-এর কম বলে এমন এক-দিকে-ভারী মিশ্রণই বেশি আসে।
  3. প্রথম শব্দ: আগে topic, পরে শব্দ। θ থেকে ছক্কা চেলে topic এল delivery (সম্ভাবনা ০.৭০), তারপর φdeliv থেকে শব্দ এল “দেরিতে” (০.৪০)।
  4. দ্বিতীয় শব্দ। আবার θ থেকে delivery, এবার শব্দ “কুরিয়ার” (০.৩০)। প্রতিটি শব্দের topic আলাদাভাবে আঁকা হয়; এটিই একটি রিভিউকে মিশ্রণ বানায়।
  5. তৃতীয় আর চতুর্থ শব্দ। দুবারই topic এল price (প্রতিবার সম্ভাবনা ০.২৫), শব্দ “দাম” (০.৪৫) আর “বেশি” (০.৪০)। রিভিউ দাঁড়াল “দেরিতে কুরিয়ার দাম বেশি”। শব্দের ক্রমের কোনো ভূমিকা নেই, এটি bag of words.
  6. দেখা শব্দের সম্ভাবনা। বাস্তবে আমরা z দেখি না, তাই সব topic-এ যোগ করি: “দাম”-এর সম্ভাবনা ০.৭০ × ০.০২ + ০.২৫ × ০.৪৫ + ০.০৫ × ০.১০ = ০.১৩১৫. চার শব্দের log সম্ভাবনার যোগফল −৬.৭২৩৫; প্রতি শব্দে গড় −১.৬৮১, তার ঋণাত্মকের exp = ৫.৩৭. এটিই এই রিভিউয়ের perplexity: model যেন প্রতিটি শব্দে প্রায় ৫.৩৭টি সমান সম্ভাব্য বিকল্পের মধ্যে দোটানায়, ছয় শব্দের পুরো অনুমানহীন অবস্থার চেয়ে একটু ভালো।

৪ · গল্প উল্টো দিকে — posterior আর scikit-learn-এ LDA

বাস্তবে আমাদের হাতে শুধু শব্দ। চাই posterior p(θ, z, φ | w, α, η): কোন θ, z আর φ এই শব্দগুলো তৈরি করে থাকতে পারে। Bayes-এর সূত্রে লেখা সহজ, কিন্তু হর (সব সম্ভাব্য z-এর ওপর যোগ, θ আর φ-এর ওপর integral) হিসাব করা অসম্ভব রকম বড়। তাই দুই ধরনের আনুমানিক পথ চলে:

  • Collapsed Gibbs sampling (Griffiths আর Steyvers, ২০০৪, PNAS): θ আর φ integral করে বাদ দিয়ে প্রতিটি শব্দের topic পালাক্রমে নতুন করে আঁকা। একটি শব্দ w-এর জন্য p(z = k | বাকি সব) ∝ (ndk + α) × (nkw + η) / (nk + Vη), যেখানে ndk = এই রিভিউয়ের কতগুলো শব্দ এখন topic k-এ, nkw = সারা সংগ্রহে w কতবার topic k-এ, nk = topic k-এর মোট শব্দ। প্রথম গুণক বলে “এই রিভিউ যে topic-এ ইতিমধ্যে ভারী”, দ্বিতীয়টি বলে “যে topic এই শব্দ পছন্দ করে”। MCMC-র বিস্তারিত পর্ব ৪৭ (MCMC — Metropolis-Hastings আর Gibbs sampling)-এ।
  • Variational inference (Blei, Ng, Jordan-এর মূল প্রবন্ধে): কঠিন posterior-এর বদলে একটি সহজ বণ্টনের পরিবার থেকে সবচেয়ে কাছেরটি খোঁজা, একটি নিম্নসীমা (ELBO) বাড়িয়ে; গঠনে EM-এর মতো পালাক্রমে দুই ধাপ। scikit-learn এই পথে চলে, Hoffman, Blei আর Bach-এর (২০১০) online variational Bayes-এর ভিত্তিতে। ধারণাটি পর্ব ৪৮ (Variational inference-এর ধারণা — ELBO আর approximation)-এ আসবে।

scikit-learn-এর LatentDirichletAllocation-এ নামগুলো: doc_topic_prior = α আর topic_word_prior = η (দুটিরই default 1/K), learning_method = "batch" (default; প্রতিবার পুরো ডেটা) বা "online" (mini-batch, বড় সংগ্রহের জন্য), max_iter (default 10), আর components_ হলো φ-এর অস্বাভাবিকৃত রূপ (প্রতিটি সারিকে তার যোগফল দিয়ে ভাগ করলে φk). fit_transform প্রতিটি রিভিউয়ের θ দেয়। নিচের ব্লক আগে tokenization-এর ফাঁদ দেখায়, তারপর K = ৫ নিয়ে LDA চালায়, শীর্ষ শব্দ ছাপে, আর generator-এর আসল বিষয়ের সঙ্গে মেলায়।

import numpy as np
import pandas as pd
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.decomposition import LatentDirichletAllocation
from bazar_reviews import make_reviews, TOPICS, STOP, TOKEN

texts, true_props, rating = make_reviews()
print("রিভিউ:", len(texts), "| নমুনা:", texts[3])
s = "রাইডার ফোন ধরেননি"
print("default token:", CountVectorizer().build_analyzer()(s))
print("বাংলা token  :", CountVectorizer(token_pattern=TOKEN).build_analyzer()(s))

vec = CountVectorizer(token_pattern=TOKEN, stop_words=STOP)
X = vec.fit_transform(texts)                     # document-term matrix (sparse)
words = vec.get_feature_names_out()
print("X:", X.shape, "| শূন্য নয় এমন ঘর:", f"{100 * X.nnz / np.prod(X.shape):.1f}%")

lda = LatentDirichletAllocation(n_components=5, learning_method="batch",
                                max_iter=30, random_state=0)
theta = lda.fit_transform(X)                     # প্রতিটি রিভিউয়ের topic-অনুপাত
for k, row in enumerate(lda.components_):
    print(f"topic {k}:", " ".join(words[np.argsort(row)[::-1][:6]]))

# কোন topic কোন আসল বিষয়ের সঙ্গে সবচেয়ে বেশি ওঠানামা করে (correlation)
C = np.corrcoef(theta.T, true_props.T)[:5, 5:]
match = C.argmax(axis=1)
print("মিল:", [TOPICS[m] for m in match])
same = match[theta.argmax(1)] == true_props.argmax(1)
print("প্রধান বিষয় মিলেছে:", f"{same.mean():.0%}")
pd.set_option("display.width", 80)
show = pd.DataFrame(theta[[2, 3, 7]].round(2),
                    columns=[f"t{k}" for k in range(5)], index=[2, 3, 7])
show["আসল"] = [" ".join(f"{TOPICS[j][:4]}{p:.2f}" for j, p in enumerate(r) if p)
              for r in true_props[[2, 3, 7]]]
print(show.to_string())

আসল আউটপুট (ব্রাউজারের Python, scikit-learn 1.8.0):

রিভিউ: 600 | নমুনা: এজেন্ট রূঢ় তবে সেলাই মজবুত
default token: ['ইড', 'ধর', 'নন']
বাংলা token  : ['রাইডার', 'ফোন', 'ধরেননি']
X: (600, 79) | শূন্য নয় এমন ঘর: 8.1%
topic 0: দাম বেশি প্যাকেট ডেলিভারি টাকা কম
topic 1: কাপড় মাপ সেলাই ঠিক নরম মান
topic 2: ফেরত এজেন্ট সহজ পাইনি নেয়নি টাকা
topic 3: প্যাকিং মোড়ক বাক্স সুন্দর দ্রুত যত্নের
topic 4: দেরিতে এসেছে কুরিয়ার তিন দেরি দিন
মিল: ['price', 'quality', 'service', 'packaging', 'delivery']
প্রধান বিষয় মিলেছে: 72%
     t0    t1    t2    t3    t4                আসল
2  0.67  0.03  0.26  0.03  0.03  pric0.67 serv0.33
3  0.04  0.44  0.44  0.04  0.04  qual0.50 serv0.50
7  0.65  0.03  0.03  0.27  0.03           pack1.00

Default pattern “রাইডার ফোন ধরেননি” থেকে অর্থহীন “ইড ধর নন” দেয়; বাংলা block-এর pattern তিনটি শব্দই অক্ষত রাখে। Stopword বাদে ৭৯টি আলাদা শব্দ, আর matrix-এর মাত্র ৮.১% ঘর শূন্য নয়। পাঁচটির চারটি topic পরিষ্কার: কাপড়-মাপ-সেলাই (মান), ফেরত-এজেন্ট (সেবা), প্যাকিং-মোড়ক-বাক্স (প্যাকেজিং), দেরিতে-কুরিয়ার (ডেলিভারি)। Topic 0 মিশ্র: দামের শব্দের সঙ্গে “প্যাকেট” আর “ডেলিভারি” (শেষেরটি “ডেলিভারি চার্জ বেশি” টুকরো থেকে)। Topic 3-এ “দ্রুত” এসেছে, যা ডেলিভারি আর সেবা দুই বিষয়েই আছে। প্রধান বিষয় ৭২% রিভিউয়ে মিলেছে; মাপটি কঠোর: রিভিউ ৩-এর মতো ৫০-৫০ রিভিউয়ে argmax প্রথমটিই বাছে, যদিও θ = ০.৪৪ আর ০.৪৪ মিশ্রণটি ঠিকই পড়েছে। রিভিউ ৭ পুরোপুরি প্যাকেজিং, অথচ θ-এর বড় ভাগ topic 0-তে; কেন, তা অংশ ৫-এ। “মিল” লাইনের নামগুলো আসল উত্তরের সঙ্গে correlation থেকে, যা বাস্তবে থাকে না; সেখানে নাম আসে শীর্ষ শব্দ আর নমুনা রিভিউ পড়ে।

Seed বদলালে কী হয় (সত্যিকারের run). LDA-র লক্ষ্য-ফাংশন convex নয়, তাই শুরুর বিন্দু অনুযায়ী আলাদা local optimum-এ থামে। একই ডেটায় চারটি seed ডেস্কটপে চালানো হয়েছে (লেখার সময়ে Python 3.14.4, scikit-learn 1.8.0; একটি local run, সাধারণ ফল নয়):

$ python seeds.py
Python 3.14.4 | scikit-learn 1.8.0
seed 0: perplexity 54.98 | আলাদা বিষয় 5/5 | প্রধান বিষয় মিলেছে 72%
seed 1: perplexity 54.62 | আলাদা বিষয় 5/5 | প্রধান বিষয় মিলেছে 77%
seed 2: perplexity 58.24 | আলাদা বিষয় 4/5 | প্রধান বিষয় মিলেছে 61%
seed 3: perplexity 53.87 | আলাদা বিষয় 5/5 | প্রধান বিষয় মিলেছে 76%

Seed ২-এ দুটি topic একই আসল বিষয়ের দিকে গেছে, আর একটি topic-এর শীর্ষ শব্দ “রাইডার গেছে কিনেছি অফারে ফেটে বোতল”, একটি জগাখিচুড়ি। এখানে (training ডেটায় মাপা) perplexity খারাপ seed-টিকে ধরেছে, কিন্তু সবসময় তা হয় না। চর্চা: কয়েকটি seed চালান, শীর্ষ শব্দ পাশাপাশি পড়ুন, আর যে topic বারবার ফিরে আসে তাকেই বেশি বিশ্বাস করুন।

৫ · Document–topic vector পড়া — কোন শব্দ কোথায় গেল

θ দেখতে সহজ, কিন্তু ভুল পড়াও সহজ। Variational LDA-তে একটি রিভিউয়ের θ আসে γk = α + (topic k-এ যাওয়া শব্দের প্রত্যাশিত সংখ্যা) থেকে, তারপর যোগফল দিয়ে ভাগ। ফলে θk ≈ (ndk + α) / (Nd + Kα), Gibbs-এর অনুমানের সূত্রও একই আকারের। ছোট রিভিউয়ে Nd ছোট, তাই prior α-র প্রভাব বড়। নিচের stepper ব্লক ১-এর রিভিউ ২ খুলে দেখে।

θk = (nk + α) / (N + Kα)α = 0.2 · K = 5 · N = 7

            
  1. রিভিউ আর তার token. “তবে” আর “একদম” stopword, তাই ৭টি token থাকে: টাকা, ফেরত, পাইনি, আর দুবার করে দাম ও সাশ্রয়ী। Generator জানে এখানে দুটি দামের টুকরো আর একটি সেবার টুকরো।
  2. θ পড়া। t0 (দামের শব্দে ভারী) ০.৬৬৬, t2 (ফেরত-এজেন্ট) ০.২৫৯, বাকি তিনটি ঠিক ০.০২৫ করে। যোগফল ১. প্রথম পড়ায় মানে: “মূলত দাম, কিছুটা সেবা”।
  3. θ থেকে শব্দ-গণনায়। θ-কে N + Kα = ৮ দিয়ে গুণ করলে γ, আর প্রতিটি থেকে α = ০.২ বাদ দিলে প্রত্যাশিত শব্দ-সংখ্যা: t0-এ ৫.১৩টি, t2-তে ১.৮৭টি, বাকিগুলোতে শূন্য। অর্থাৎ ০.০২৫ কোনো প্রমাণ নয়, শুধু prior. সাত শব্দের রিভিউয়ে prior মোট ভাগের ১/৮ দখল করে।
  4. প্রতিটি শব্দের ভাগ। দাম, সাশ্রয়ী পুরোপুরি t0-এ, ফেরত পুরোপুরি t2-তে। ভাগাভাগি শব্দ “টাকা” ০.৭২ ভাগ t0-এ, “পাইনি” ০.৪১ ভাগ t0-এ। যোগ করলে t0 = ২ + ২ + ০.৭২ + ০.৪১ = ৫.১৩, ঠিক আগের ধাপের সংখ্যা।
  5. আসলের সঙ্গে তুলনা। শব্দ গুনলে আসল ভাগ দাম ০.৫৭, সেবা ০.৪৩; LDA সেবাকে কম দেয় (০.২৬)। কারণ “টাকা” আর “পাইনি” দুই বিষয়েই আসে, আর রিভিউটি ইতিমধ্যে দামে ভারী বলে ভাগাভাগি শব্দ সেদিকে টানে: Gibbs সূত্রের (ndk + α) গুণকের “যার আছে সে আরও পায়” প্রভাব।
  6. Topic-এর নাম সাবধানে। রিভিউ ৭ পুরোপুরি প্যাকেজিং, অথচ θ-এর ০.৬৫ t0-তে। t0-এর শীর্ষ শব্দে “প্যাকেট” আছে: t0 আসলে “দাম + ভাঙা প্যাকেট”-এর মিশ্র topic. “t0 = দাম” ধরে নিলে এই রিভিউ ভুল ঘরে গোনা হতো। নাম দেওয়ার আগে প্রতিটি topic-এর সবচেয়ে ভারী রিভিউগুলো পড়ুন।

৬ · NMF আর LSA পাশাপাশি — TF-IDF-এ অঋণাত্মক topic

Non-negative matrix factorization (NMF) একই X ≈ WH লেখে, কিন্তু শর্ত দেয় W আর H-এর সব সংখ্যা ≥ ০. Lee আর Seung ১৯৯৯ সালে Nature-এ দেখান, এই একটি শর্তে অংশগুলো শুধু যোগ হতে পারে, বিয়োগ নয়, তাই মুখের ছবিতে NMF চোখ-নাক-ঠোঁটের মতো “অংশ” শেখে (এর আগে Paatero আর Tapper ১৯৯৪ সালে positive matrix factorization নামে একই ধারণা দেন)। লেখায় W = রিভিউ × topic, H = topic × শব্দ, আর একটি রিভিউ হলো কয়েকটি topic-এর অঋণাত্মক যোগফল: LDA-র মতোই পড়া যায়, যদিও সম্ভাবনার গল্প নেই। NMF-এর update নিয়ম আর অন্য প্রয়োগ পর্ব ৪১ (PCA-র বাইরে — kernel PCA, ICA আর NMF)-এ। বাস্তবে topic-এর জন্য NMF সাধারণত TF-IDF-এ চালানো হয়, কারণ খুব সাধারণ শব্দের ওজন তাতে কমে। নিচের ব্লক একই রিভিউয়ে NMF আর LSA-র (TruncatedSVD) শীর্ষ শব্দ পাশাপাশি দেখায়; LSA-র প্রতিটি দিকের ধনাত্মক আর ঋণাত্মক দুই প্রান্তই।

import numpy as np
import pandas as pd
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.decomposition import NMF, TruncatedSVD
from bazar_reviews import make_reviews, TOPICS, STOP, TOKEN

texts, true_props, rating = make_reviews()
tfidf = TfidfVectorizer(token_pattern=TOKEN, stop_words=STOP)
X = tfidf.fit_transform(texts)
words = tfidf.get_feature_names_out()

nmf = NMF(n_components=5, init="nndsvda", max_iter=500, random_state=0)
W = nmf.fit_transform(X)                  # রিভিউ × topic, সব ≥ 0
H = nmf.components_                       # topic × শব্দ, সব ≥ 0
lsa = TruncatedSVD(n_components=5, random_state=0).fit(X)

def top(row, n=4):                        # সবচেয়ে বড় n-টি ওজনের শব্দ
    return " ".join(words[np.argsort(row)[::-1][:n]])

table = pd.DataFrame({"NMF": [top(h) for h in H],
                      "LSA (+)": [top(v) for v in lsa.components_],
                      "LSA (−)": [top(-v) for v in lsa.components_]})
pd.set_option("display.width", 90)
print(table.to_string())
print("NMF-এ ঋণাত্মক ওজন:", (H < 0).sum(), "| LSA-তে:", (lsa.components_ < 0).sum())
print("NMF reconstruction error:", round(nmf.reconstruction_err_, 2))
share = W / W.sum(axis=1, keepdims=True).clip(1e-12)
C = np.corrcoef(share.T, true_props.T)[:5, 5:]
print("NMF topic → আসল বিষয়:", [TOPICS[m] for m in C.argmax(axis=1)])
print("correlation:", C.max(axis=1).round(2))

আসল আউটপুট (ব্রাউজারের Python, scikit-learn 1.8.0):

                            NMF                    LSA (+)                       LSA (−)
0         ফেরত এজেন্ট সহজ পাইনি         দাম ফেরত বেশি টাকা     খারাপ হাতে পেয়েছি পরদিনই
1          দাম বেশি কম সাশ্রয়ী        দাম বেশি কম বাজারের        ফেরত সহজ এজেন্ট নেয়নি
2    প্যাকিং মোড়ক বাক্স সুন্দর     ফেরত টাকা এজেন্ট পাইনি    প্যাকিং মোড়ক সুন্দর মজবুত
3  দেরিতে কুরিয়ার এসেছে রাইডার  দেরিতে কুরিয়ার এসেছে মাপ     মোড়ক ফেরত প্যাকিং যত্নের
4             মাপ কাপড় ঠিক নরম          মাপ কাপড় ঠিক নরম  রাইডার দেরিতে কুরিয়ার এসেছে
NMF-এ ঋণাত্মক ওজন: 0 | LSA-তে: 193
NMF reconstruction error: 20.78
NMF topic → আসল বিষয়: ['service', 'price', 'packaging', 'delivery', 'quality']
correlation: [0.92 0.95 0.96 0.91 0.94]

NMF-এর পাঁচটি topic পাঁচটি আলাদা আসল বিষয়ে যায়, আর রিভিউপ্রতি ভাগের correlation ০.৯১ থেকে ০.৯৬; এই ডেটায় আর এই এক init-এ এটি LDA-র চেয়ে পরিষ্কার, যদিও তা সাধারণ নিয়ম নয়। LSA-র ৫ × ৭৯ = ৩৯৫টি ওজনের ১৯৩টি ঋণাত্মক। প্রথম দিকের ধনাত্মক প্রান্তে সবচেয়ে ঘন ঘন আসা শব্দ (দাম, ফেরত, বেশি, টাকা): এটি “গড় রিভিউ”-এর দিক, কারণ গড় বাদ দেওয়া হয়নি; তার ঋণাত্মক প্রান্ত শুধু প্রায়-শূন্য ওজনের এলোমেলো শব্দ। দ্বিতীয় দিক দাম বনাম সেবা, শেষ দিক মান বনাম ডেলিভারি: লম্ব হওয়ার শর্তে LSA “বিষয়” না দিয়ে দুই বিষয়ের তুলনার অক্ষ দেয়। NMF-এর reconstruction error ২০.৭৮ হলো ‖X − WH‖F.

দিকLSA (TruncatedSVD)NMFLDA
ইনপুটTF-IDFTF-IDF (বা গণনা)শুধু গণনা
ওজনের চিহ্নধনাত্মক ও ঋণাত্মকসব ≥ ০সব সম্ভাবনা, যোগফল ১
লক্ষ্যFrobenius ভুল, সবচেয়ে ভালো rank-k (Eckart–Young)Frobenius (বা KL) ভুল, অঋণাত্মক শর্তেDirichlet prior-সহ likelihood (আনুমানিক posterior)
ফল একক কি নাহ্যাঁ (চিহ্ন ছাড়া)না, init-নির্ভরনা, seed-নির্ভর
নতুন রিভিউtransformtransform (ছোট optimization)transform (posterior অনুমান)
কখন ভালোখোঁজ, মাত্রা কমানোদ্রুত, পড়ার মতো topicঅনিশ্চয়তা, prior আর সম্প্রসারণ (সময়, লেখক) দরকার হলে

৭ · কয়টি topic? Perplexity, coherence আর মানুষের বিচার

K বাছার কোনো নিখুঁত নিয়ম নেই। তিনটি সাক্ষী আছে, আর তারা প্রায়ই একমত হয় না।

১. Held-out perplexity. কিছু রিভিউ আলাদা রেখে, বাকিগুলোতে শিখে, আলাদা রাখা রিভিউয়ের শব্দকে model কত সম্ভাব্য বলে তা মাপা: perplexity = exp(−∑ log p(w) / Nশব্দ), কম হলে ভালো। স্টেপার ১-এর শেষ ধাপে এটি হাতে করেছেন। scikit-learn-এর perplexity variational নিম্নসীমা দিয়ে আনুমানিক মান দেয়, আর নতুন রিভিউয়ের θ নিজেই অনুমান করে; তাই এটি নিখুঁত held-out likelihood নয়। AI-এর ভিত্তি কোর্সের পর্ব ৩৮-এর cross-entropy-র exp-ই এই সংখ্যা।

২. Topic coherence. একটি topic-এর শীর্ষ শব্দগুলো কি সত্যিই লেখায় একসঙ্গে আসে? UMass coherence (Mimno ও সহকর্মীরা, ২০১১) শীর্ষ M শব্দ w1, …, wM (বড় থেকে ছোট) নিয়ে প্রতিটি জোড়া l < m-এ যোগ করে log((D(wm, wl) + ১) / D(wl)), যেখানে D(w) = কতগুলো রিভিউতে w আছে, D(wm, wl) = কতগুলোতে দুটিই। মান প্রায় সবসময় ঋণাত্মক, ০-এর কাছে হলে ভালো। নিচের ব্লকে যোগফলের বদলে গড় নেওয়া হয়েছে, যাতে আলাদা M-এ তুলনা চলে। অন্য রূপ আছে (NPMI, CV), আর সেগুলো প্রায়ই বাইরের বড় সংগ্রহে co-occurrence গোনে।

৩. মানুষের বিচার আর কাজের প্রশ্ন। শীর্ষ শব্দ আর প্রতিটি topic-এর সবচেয়ে ভারী কয়েকটি রিভিউ পড়া। Topic-এর সংখ্যা শেষ পর্যন্ত একটি সিদ্ধান্ত: ড্যাশবোর্ডে পাঁচটি বড় বিষয় চাই, নাকি অপারেশন দলের জন্য বিশটি সূক্ষ্ম বিষয়?

নিচের ব্লক একই generator থেকে ৯০০টি রিভিউ নেয় (প্রথম ৬০০টি আগের মতোই), ৬০০টিতে শেখে আর শেষ ৩০০টিতে perplexity মাপে, আর UMass নিজে হাতে লেখা। সাতটি K-তে model লাগে, তাই কয়েক সেকেন্ড সময় নেয়।

import numpy as np
import matplotlib.pyplot as plt
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.decomposition import LatentDirichletAllocation
from bazar_reviews import make_reviews, STOP, TOKEN

texts, _, _ = make_reviews(n=900)          # আগের ৬০০টি + নতুন ৩০০টি
vec = CountVectorizer(token_pattern=TOKEN, stop_words=STOP)
X = vec.fit_transform(texts)
X_tr, X_te = X[:600], X[600:]                    # শেষ ৩০০টি রিভিউ আলাদা রাখা
B = (X_tr > 0).toarray().astype(float)           # শব্দটি রিভিউতে আছে কি না
D = B.T @ B                                      # D[i, j] = কতগুলো রিভিউতে i আর j দুটিই

def umass(top):                                  # Mimno et al. 2011-এর UMass coherence
    s = [np.log((D[top[m], top[l]] + 1) / D[top[l], top[l]])
         for m in range(1, len(top)) for l in range(m)]
    return np.mean(s)

Ks, perp, coh = [2, 3, 4, 5, 6, 8, 10], [], []
for K in Ks:
    lda = LatentDirichletAllocation(n_components=K, learning_method="batch",
                                    max_iter=20, random_state=0).fit(X_tr)
    perp.append(lda.perplexity(X_te))            # আলাদা রাখা রিভিউয়ে
    tops = [np.argsort(r)[::-1][:6] for r in lda.components_]
    coh.append(np.mean([umass(t) for t in tops]))
    print(f"K={K}: held-out perplexity {perp[-1]:6.2f} | UMass {coh[-1]:6.2f}")

fig, (a1, a2) = plt.subplots(1, 2, figsize=(8, 3))
a1.plot(Ks, perp, "o-"); a1.set_title("Held-out perplexity (lower = better)")
a2.plot(Ks, coh, "o-", color="C1"); a2.set_title("UMass coherence (higher = better)")
for a in (a1, a2):
    a.set_xlabel("number of topics K"); a.axvline(5, color="0.7", ls="--")
fig.subplots_adjust(wspace=0.3, bottom=0.18)
plt.show()

আসল আউটপুট (ব্রাউজারের Python, scikit-learn 1.8.0):

K=2: held-out perplexity  72.64 | UMass  -2.17
K=3: held-out perplexity  68.68 | UMass  -1.80
K=4: held-out perplexity  65.54 | UMass  -1.55
K=5: held-out perplexity  64.14 | UMass  -1.63
K=6: held-out perplexity  64.15 | UMass  -1.70
K=8: held-out perplexity  61.54 | UMass  -1.77
K=10: held-out perplexity  58.40 | UMass  -1.69

Held-out perplexity K = ১০ পর্যন্ত কমতেই থাকে; শুধু ৫ থেকে ৬-এ সমান (৬৪.১৪ আর ৬৪.১৫), একটি ছোট “থামার” ইঙ্গিত। UMass সবচেয়ে ভালো K = ৪-এ (−১.৫৫)। আসল উত্তর ৫, অথচ কোনো সংখ্যাই সরাসরি ৫ বলে না। এটি একটি seed আর একটি ভাগের ফল; seed বদলালে বিন্দুগুলো একটু নড়ে। ছবির দুই প্যানেলে ধূসর দাগ K = ৫-এ। কেন এমন হয়, নিচের stepper খুলে দেখায়।

৬০০টিতে শেখা, ৩০০টিতে মাপাআসল বিষয় = 5

            
  1. Perplexity যা বলে। আলাদা রাখা ৩০০টি রিভিউয়ে K = ২ থেকে ১০ পর্যন্ত perplexity প্রায় একটানা কমে, ৭২.৬৪ থেকে ৫৮.৪০. শুধু ৫ থেকে ৬-এ সমান থাকে। এই সংখ্যা একা দেখলে সিদ্ধান্ত হতো “আরও topic”.
  2. Coherence যা বলে। গড় UMass সবচেয়ে ভালো K = ৪-এ। কিন্তু K = ৪-এর প্রথম topic “দাম বেশি মোড়ক বাক্স”: দুটি আলাদা বিষয় মিশে আছে, আর তার নিজের UMass সবচেয়ে খারাপ (−১.৯৪)। গড় সেই মিশ্রণ ঢেকে দিয়েছে।
  3. K = ৫ পড়ে দেখা। চারটি topic পরিষ্কার, একটি (দাম + প্যাকেট + ডেলিভারি) মিশ্র, আর তার UMass সবচেয়ে কম (−২.০২)। এখানে প্রতিটি topic-এর আলাদা coherence গড়ের চেয়ে বেশি কাজের: কোন topic সন্দেহজনক তা দেখায়।
  4. K = ১০ পড়ে দেখা। কিছু topic বিষয়ের ভেতরের সূক্ষ্ম ভাগ (রাইডার-ফোন বনাম কুরিয়ার-দেরিতে, হেল্পলাইন বনাম ফেরত), যা কাজে লাগতে পারে; কিছু জগাখিচুড়ি (“এজেন্ট দেরি দিন তিন”, “কাপড় নরম অভিযোগ মিটেছে”)। এগুলো ডেস্কটপে একই সেটিংসে হিসাব করা।
  5. Perplexity কেন বেশি topic চায়। Perplexity মাপে পরের শব্দ কতটা অনুমান করা যায়। আমাদের রিভিউয়ে স্থির শব্দ-জোড়া আছে; বেশি topic সেই জোড়াগুলো আলাদা করে মনে রাখে, তাই শব্দের অনুমান ভালো হয়, যদিও মানুষের কাছে “রাইডার” আর “কুরিয়ার” একই ডেলিভারি বিষয়। লক্ষ্য আলাদা, তাই রায়ও আলাদা।
  6. সিদ্ধান্ত মানুষের। Chang ও সহকর্মীরা ২০০৯ সালে মানুষ দিয়ে topic পরীক্ষা করে দেখান, ভালো held-out likelihood মানেই বেশি পড়ার মতো topic নয়। তাই প্রশ্নটি কাজের: সাপ্তাহিক ড্যাশবোর্ডে পাঁচটি বিষয়, অপারেশন দলের জন্য আট-দশটি, পড়ে মিশ্রগুলো বাদ দিয়ে, কয়েকটি seed-এ যাচাই করে।

৮ · Topic-অনুপাত feature হিসেবে — অভিযোগ ধরা যায় কি?

θ প্রতিটি রিভিউকে K-টি সংখ্যায় নামিয়ে আনে, তাই একে classifier-এর feature বানানো লোভনীয়: পাঁচটি ব্যাখ্যাযোগ্য সংখ্যা, হাজার শব্দের বদলে। দুটি নিয়ম মানতে হবে। প্রথমত, LDA নিজেও ডেটা থেকে শেখে, তাই cross-validation-এর প্রতিটি fold-এ শুধু train অংশে fit করতে হবে; পুরো ডেটায় আগে LDA চালিয়ে তারপর ভাগ করলে test রিভিউয়ের তথ্য feature-এ ঢুকে যায় (data leakage; AI-এর ভিত্তি কোর্সের পর্ব ৬৪-এর pipeline ঠিক এজন্য)। দ্বিতীয়ত, একটি সরল baseline-এর পাশে মাপতে হবে। নিচের ব্লক ১–২ তারার রিভিউকে “অভিযোগ” ধরে তিনটি জিনিস তুলনা করে: LDA-র θ দিয়ে logistic regression, সব শব্দের TF-IDF দিয়ে logistic regression, আর generator-এর আসল বিষয়-অনুপাত দিয়ে (যা বাস্তবে কখনো পাওয়া যায় না, শুধু ঊর্ধ্বসীমা দেখতে)। মাপকাঠি পাঁচ-fold cross-validation-এ ROC AUC (AI-এর ভিত্তি কোর্সের পর্ব ৬০)।

import numpy as np
from sklearn.feature_extraction.text import CountVectorizer, TfidfVectorizer
from sklearn.decomposition import LatentDirichletAllocation
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline
from sklearn.model_selection import cross_val_score, StratifiedKFold
from bazar_reviews import make_reviews, STOP, TOKEN

texts, true_props, rating = make_reviews()
y = (rating <= 2).astype(int)                     # ১–২ তারা = অভিযোগ
print("অভিযোগের হার:", round(y.mean(), 3))
cv = StratifiedKFold(5, shuffle=True, random_state=0)

def lda_model(K):                                 # LDA প্রতিটি fold-এর train অংশেই শেখে
    return make_pipeline(
        CountVectorizer(token_pattern=TOKEN, stop_words=STOP),
        LatentDirichletAllocation(n_components=K, max_iter=20, random_state=0),
        LogisticRegression(max_iter=1000))

models = {
    "LDA θ, K=5": lda_model(5),
    "TF-IDF সব শব্দ": make_pipeline(
        TfidfVectorizer(token_pattern=TOKEN, stop_words=STOP),
        LogisticRegression(max_iter=1000)),
}
for name, m in models.items():
    auc = cross_val_score(m, texts, y, cv=cv, scoring="roc_auc")
    print(f"{name:15s} ROC AUC {auc.mean():.3f} ± {auc.std():.3f}")
auc = cross_val_score(LogisticRegression(), true_props, y, cv=cv, scoring="roc_auc")
print(f"{'আসল অনুপাত':15s} ROC AUC {auc.mean():.3f} ± {auc.std():.3f}")

আসল আউটপুট (ব্রাউজারের Python, scikit-learn 1.8.0):

অভিযোগের হার: 0.3
LDA θ, K=5      ROC AUC 0.589 ± 0.057
TF-IDF সব শব্দ  ROC AUC 0.941 ± 0.015
আসল অনুপাত      ROC AUC 0.607 ± 0.063

৩০% রিভিউ অভিযোগ। পাঁচটি topic-অনুপাত দিয়ে ROC AUC ০.৫৮৯, এলোমেলো অনুমানের (০.৫) সামান্য ওপরে; সব শব্দের TF-IDF-এ ০.৯৪১. এমনকি generator-এর আসল বিষয়-অনুপাতও মাত্র ০.৬০৭ দেয়, তাই দোষ LDA-র নয়। কারণ রেটিং নির্ভর করে টুকরোটি ভালো না খারাপ তার ওপর, কোন বিষয়ের তার ওপর সামান্য (বিষয়ভেদে অভিযোগের সম্ভাবনা ০.৩ থেকে ০.৬)। “ছেঁড়া”, “দেরিতে”, “সাশ্রয়ী”-র মতো শব্দ অনুভূতি বহন করে, topic তা গড় করে মুছে ফেলে। শিক্ষা: topic-অনুপাত কী নিয়ে লেখা তার সারাংশ, যেমন প্রতিটি topic-এ অভিযোগের হার দেখানো; অনুমানের feature হিসেবে সবসময় একটি bag-of-words baseline-এর পাশে মাপুন। বাস্তব ডেটায় বিষয় আর অনুভূতি বেশি জড়িত থাকলে ফল অন্যরকম হতে পারে।

৯ · সীমাবদ্ধতা আর আধুনিক বিকল্প — ছোট লেখা, বাংলা রূপতত্ত্ব, BERTopic

  • ছোট লেখা। একটি রিভিউয়ে সাত-আটটি শব্দ থাকলে co-occurrence-এর প্রমাণ খুব কম, আর θ-তে prior-এর ভাগ বড় (অংশ ৫)। Tweet আর রিভিউয়ের জন্য আলাদা model তৈরি হয়েছে, যেমন প্রতি লেখায় একটি topic ধরে নেওয়া Dirichlet multinomial mixture (Yin আর Wang-এর GSDMM, ২০১৪) বা পুরো সংগ্রহের শব্দ-জোড়া থেকে শেখা biterm topic model (Yan ও সহকর্মীরা, ২০১৩)।
  • বাংলা রূপতত্ত্ব। “ডেলিভারি”, “ডেলিভারিতে”, “ডেলিভারির” তিনটি আলাদা কলাম হয়ে যায়, ফলে প্রমাণ ভাগ হয়ে দুর্বল হয়। বাংলায় নির্ভরযোগ্য stemmer বা lemmatizer এখনো সীমিত; সাবধানে লেখা কয়েকটি suffix-ছাঁটার নিয়ম বা character n-gram সাহায্য করতে পারে, কিন্তু “দেরি” আর “দেরিতে” মেলাতে গিয়ে ভুল শব্দও মিশে যেতে পারে। Banglish আর বানানভেদও একই সমস্যা বাড়ায়।
  • Stopword আর অস্বীকার। কোন শব্দ বাদ যাবে তা topic বদলে দেয়। “না/নেই/পাইনি” রাখলে অভিযোগের topic স্পষ্ট হয়; bag of words-এ “ভালো না” আর “ভালো” তবু কাছাকাছি থাকে।
  • Topic-এর নাম মানুষের দেওয়া। Model শুধু শব্দের দল দেয়; “ডেলিভারি” নামটি আপনার। ভুল নাম দিলে পুরো রিপোর্ট ভুল দিকে যায় (অংশ ৫-এর রিভিউ ৭)।
  • Embedding-ভিত্তিক topic model. BERTopic (Maarten Grootendorst, ২০২২ সালের arXiv প্রবন্ধ) প্রতিটি লেখাকে একটি pretrained ভাষা-model-এর embedding-এ বদলায়, UMAP দিয়ে মাত্রা কমায়, HDBSCAN দিয়ে cluster করে, তারপর প্রতিটি cluster-এর শব্দ class-based TF-IDF দিয়ে বাছে। এতে প্রতিশব্দ আর শব্দের রূপভেদ embedding-এ কাছাকাছি আসতে পারে, তাই ছোট লেখায় অনেক সময় বেশি পড়ার মতো topic পাওয়া যায় বলে বহু ব্যবহারকারী জানান; তবে ফল নির্ভর করে embedding model-টি বাংলা কতটা ভালো ধরে তার ওপর, ডিফল্টে প্রতিটি লেখা একটি topic পায়, আর UMAP/HDBSCAN-এর parameter-এ ফল অনেক বদলায় (পর্ব ৪২, t-SNE আর UMAP — manifold learning আর তার ফাঁদ, সেখানকার সতর্কতা এখানেও খাটে)। এর গভীর আলোচনা পরের NLP আর generative AI কোর্সে আসবে।

আরও গভীরে: NMF-কে Frobenius-এর বদলে KL-divergence loss দিয়ে চালালে (beta_loss="kullback-leibler", solver="mu") তার optimum pLSA-র maximum likelihood-এর সঙ্গে মেলে (Gaussier আর Goutte, ২০০৫), তাই NMF, pLSA আর LDA একই পরিবারের তিন সদস্য, পার্থক্য prior আর loss-এ। LDA থেকে অনেক সম্প্রসারণ এসেছে: সময়ের সঙ্গে topic বদলানো (dynamic topic model, Blei আর Lafferty, ২০০৬), topic-এর মধ্যে correlation (CTM), আর metadata দিয়ে topic-এর ভাগ ব্যাখ্যা (structural topic model, যা সমাজবিজ্ঞানে বহুল ব্যবহৃত)। Hoyle ও সহকর্মীদের ২০২১ সালের NeurIPS প্রবন্ধ “Is automated topic model evaluation broken?” দেখায়, স্বয়ংক্রিয় coherence মাপ আর মানুষের রায় সবসময় একসঙ্গে চলে না।

পরীক্ষা ও ইন্টারভিউতে: এই ধরনের প্রশ্ন প্রায়ই আসে।
১. LDA-র generative গল্প লিখুন। → φk ~ Dir(η); θd ~ Dir(α); প্রতিটি শব্দে z ~ Cat(θd), w ~ Cat(φz).
২. α ছোট হলে কী হয়? → প্রতিটি নথি অল্প কয়েকটি topic-এ ভারী (sparse মিশ্রণ); বড় হলে সমান মিশ্রণ।
৩. θ = (০.৬, ০.৪), φ1(w) = ০.১, φ2(w) = ০.৩ হলে p(w | d)? → ০.৬ × ০.১ + ০.৪ × ০.৩ = ০.১৮.
৪. LSA, NMF আর LDA-র পার্থক্য? → SVD (ঋণাত্মক ওজন, লম্ব দিক); অঋণাত্মক factorization; prior-সহ সম্ভাবনার model.
৫. K কীভাবে বাছবেন? → held-out perplexity, coherence আর শীর্ষ শব্দ ও নমুনা নথি পড়া একসঙ্গে, কয়েকটি seed-এ; কাজের প্রয়োজনই শেষ বিচারক।

১০ · AI-এর যুগে — topic model-এর কোড আর ব্যাখ্যা যাচাই

AI assistant কয়েক সেকেন্ডে topic modeling-এর কোড আর topic-এর সুন্দর নাম দেয়। বিপদ এখানেই: ভুল tokenization, বানানো parameter আর আত্মবিশ্বাসী নাম মিলে ভুল রিপোর্টও বিশ্বাসযোগ্য দেখায়।

AI প্রায়ই যা করেকেন ভুলকীভাবে ধরবেন
Default CountVectorizer() বাংলায় চালায়কার-চিহ্নে শব্দ ভাঙে, vocabulary টুকরোয় ভরেget_feature_names_out()-এর প্রথম ৩০টি পড়ুন
পুরোনো বা বানানো parameter দেয় (n_topics, gensim-এর num_topics, NMF-এর পুরোনো alpha)scikit-learn-এ এখন n_components, NMF-এ alpha_W/alpha_Hনিজের version-এর অফিসিয়াল docs; কোড চালিয়ে দেখুন
Training perplexity সবচেয়ে কম দেখে K বাছেK বাড়লে training perplexity প্রায় সবসময় কমেHeld-out perplexity, coherence আর শীর্ষ শব্দ একসঙ্গে, কয়েকটি seed-এ
পুরো ডেটায় LDA চালিয়ে θ দিয়ে classifier মাপেLeakage; test রিভিউ topic শেখায় অংশ নিয়েছেPipeline-এ LDA রাখুন, প্রতিটি fold-এ নতুন fit
শীর্ষ তিন শব্দ দেখে topic-এর নাম দিয়ে “২৮% ক্রেতা ডেলিভারি নিয়ে অসন্তুষ্ট” লেখেTopic অনুভূতি মাপে না; নাম অনুমান; θ-এর গড় মানুষের শতাংশ নয়প্রতিটি topic-এর ২০টি ভারী রিভিউ পড়ুন; শতাংশ দাবির আগে হাতে label করা নমুনায় যাচাই

কোডের সঙ্গে যাচাই আর সীমাবদ্ধতাও চেয়ে নিন:

আমার কাছে প্রায় ৫,০০০টি ছোট বাংলা পণ্য-রিভিউ আছে (কিছুতে Banglish মেশানো)। scikit-learn 1.8 দিয়ে topic modeling করতে চাই। ১. বাংলা কার-চিহ্ন না হারায় এমন token_pattern দাও, আর default pattern কেন ভুল তা একটি উদাহরণে দেখাও। ২. CountVectorizer + LatentDirichletAllocation আর TfidfVectorizer + NMF দুটোর কোড দাও, শুধু এই version-এ আছে এমন parameter দিয়ে; প্রতিটি parameter-এর অর্থ এক লাইনে বলো। ৩. K = 3 থেকে 15-এ held-out perplexity আর নিজে লেখা UMass coherence মাপার কোড দাও, কয়েকটি seed-সহ। ৪. topic-অনুপাত দিয়ে রেটিং classifier মাপার এমন pipeline দাও যাতে কোনো leakage না হয়, আর TF-IDF baseline-এর সঙ্গে তুলনা করো। ৫. তোমার কোডে কোন জায়গায় ভুল হতে পারে বা কোন অনুমান যাচাই করা দরকার, আলাদা করে বলো। topic-এর নাম নিজে বানিয়ো না।

AI-এর দেওয়া topic modeling-এর কোড বা রিপোর্ট হাতে পেলে — যাচাইয়ের চেকলিস্ট

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।

প্র ০১LSA (১৯৯০) থেকে pLSA (১৯৯৯) হয়ে LDA (২০০৩): প্রতিটি ধাপে কোন সমস্যার সমাধান হলো, আর prior যোগ করা কেন এত বড় পরিবর্তন?

LSA এসেছিল তথ্য খোঁজার (information retrieval) সমস্যা থেকে: “গাড়ি” খুঁজলে “মোটরযান” লেখা নথি বাদ পড়ত। Deerwester ও সহকর্মীরা দেখান, term-document matrix-এর truncated SVD একসঙ্গে আসা শব্দকে কাছাকাছি আনে, তাই প্রতিশব্দের সমস্যা কমে। কিন্তু SVD একটি জ্যামিতিক উত্তর: ঋণাত্মক ওজন, লম্ব দিক, আর Frobenius ভুল, যা শব্দ-গণনার মতো ডেটার জন্য স্বাভাবিক মাপ নয়।

Hofmann-এর pLSA একই কাঠামোকে সম্ভাবনার ভাষায় লেখে: প্রতিটি নথি topic-এর একটি বণ্টন, প্রতিটি topic শব্দের একটি বণ্টন, আর শেখা হয় EM দিয়ে likelihood বাড়িয়ে। এতে সংখ্যাগুলো অর্থবহ হলো। কিন্তু প্রতিটি নথির মিশ্রণ আলাদা parameter, তাই parameter-এর সংখ্যা নথির সঙ্গে রৈখিকভাবে বাড়ে, overfit সহজ, আর নতুন নথি এলে তার মিশ্রণ বের করার নীতিগত পথ নেই।

Blei, Ng আর Jordan মিশ্রণগুলোকে parameter না রেখে একটি Dirichlet বণ্টন থেকে আসা random variable বানালেন। এখন parameter শুধু α আর topic-গুলো, নথি যতই হোক; নতুন নথির মিশ্রণ posterior হিসেবে অনুমান করা যায়; আর α দিয়ে বলা যায় “একটি নথি সাধারণত অল্প কয়েকটি topic-এ”। Girolami আর Kabán ২০০৩ সালে দেখান, সমান Dirichlet prior-এ LDA-র একটি বিশেষ অনুমান pLSA-র সঙ্গে মিলে যায়, অর্থাৎ পার্থক্য মূলত prior আর পুরো posterior ব্যবহারে। Prior-এর আরেকটি লাভ সম্প্রসারণ: সময়, লেখক বা metadata যোগ করতে শুধু গল্পে একটি ধাপ যোগ করতে হয়।

মূল উপলব্ধি: একই “নথি = topic-এর মিশ্রণ” ধারণা তিনবার নতুন রূপ পেয়েছে: জ্যামিতি, likelihood, তারপর পূর্ণ generative model. প্রতিটি ধাপ আগেরটির একটি নির্দিষ্ট দুর্বলতা সারিয়েছে।

প্র ০২“Reading tea leaves” প্রবন্ধ কী দেখিয়েছিল, আর তার পর topic model মূল্যায়নের চর্চা কীভাবে বদলেছে?

২০০৯ সালের NIPS-এ Jonathan Chang, Jordan Boyd-Graber ও সহকর্মীরা একটি সহজ প্রশ্ন তোলেন: topic model-এর লক্ষ্য যদি মানুষের বোঝার মতো বিষয় দেওয়া হয়, তবে মানুষ দিয়েই তা মাপা হয় না কেন? তাঁরা দুটি পরীক্ষা বানান। “Word intrusion”-এ একটি topic-এর পাঁচটি শীর্ষ শব্দের মধ্যে অন্য topic-এর একটি শব্দ লুকিয়ে রাখা হয়; topic সুসংগত হলে মানুষ সহজে বেমানানটি ধরতে পারেন। “Topic intrusion”-এ একটি নথির সঙ্গে তার প্রধান topic-গুলো আর একটি বাইরের topic দেখানো হয়। তাঁদের পরীক্ষায় held-out likelihood ভালো এমন model প্রায়ই মানুষের পরীক্ষায় ভালো করেনি, কিছু ক্ষেত্রে উল্টো দিকেও গেছে।

এরপর মানুষের রায়ের কাছাকাছি স্বয়ংক্রিয় মাপ খোঁজা শুরু হয়। Newman ও সহকর্মীরা (২০১০) Wikipedia-র মতো বাইরের বড় সংগ্রহে শীর্ষ শব্দ-জোড়ার PMI মাপেন; Mimno ও সহকর্মীরা (২০১১) একই সংগ্রহের নথি-গণনা দিয়ে UMass দেন, যা এই পর্বে নিজে লিখেছেন; Röder ও সহকর্মীরা (২০১৫) অনেক মাপ তুলনা করে CV-এর মতো সমন্বিত মাপ প্রস্তাব করেন। পরে Hoyle ও সহকর্মীরা (২০২১) সতর্ক করেন, এই স্বয়ংক্রিয় মাপ মূলত পুরোনো ধাঁচের model-এ যাচাই করা, নতুন neural topic model-এ মানুষের রায়ের সঙ্গে তাদের মিল দুর্বল হতে পারে।

ব্যবহারিক শিক্ষা: perplexity, coherence আর মানুষের পড়া তিনটিই লিখে রাখুন, সংখ্যা দেখে নয়, শীর্ষ শব্দ আর নমুনা নথি পড়ে topic-এর মান বিচার করুন, আর গুরুত্বপূর্ণ সিদ্ধান্তে ছোট একটি word-intrusion পরীক্ষা নিজের দলেই চালান।

মূল উপলব্ধি: যে লক্ষ্য মানুষের বোঝা, তার চূড়ান্ত মাপও মানুষ; স্বয়ংক্রিয় সংখ্যা সেই রায়ের সস্তা কিন্তু অসম্পূর্ণ বদলি।

প্র ০৩ছোট লেখায় (রিভিউ, SMS, মন্তব্য) LDA কেন হোঁচট খায়, আর বাজারঘরের মতো দোকানের রিভিউয়ে কী কী বিকল্প চেষ্টা করবেন?

LDA শেখে co-occurrence থেকে: দুটি শব্দ একই নথিতে বারবার এলে তারা একই topic-এ যায়। খবরের প্রবন্ধে শত শত শব্দ থাকে, তাই প্রতিটি নথি থেকে অনেক জোড়ার প্রমাণ আসে। সাত-আট শব্দের রিভিউয়ে জোড়া মাত্র কয়েকটি, আর অংশ ৫-এ দেখেছেন, সেখানে θ-এর বড় ভাগ prior থেকেই আসে। ফলে topic ঝাপসা হয়, seed-এ বেশি বদলায়, আর ভাগাভাগি শব্দ ভুল দিকে টানে।

গবেষণায় কয়েকটি পথ এসেছে। Yin আর Wang-এর (২০১৪) GSDMM ধরে নেয় একটি ছোট লেখায় একটিই topic, যা অনেক রিভিউয়ের ক্ষেত্রে বাস্তবের কাছাকাছি; Yan ও সহকর্মীদের (২০১৩) biterm topic model নথির বদলে পুরো সংগ্রহের শব্দ-জোড়া (biterm) থেকে শেখে, তাই ছোট নথির ঘাটতি এড়ায়। সহজ কৌশল “pooling”: একই পণ্য, একই বিক্রেতা বা একই দিনের রিভিউ জোড়া দিয়ে বড় নথি বানানো, যদিও তাতে রিভিউপ্রতি θ হারায়। Embedding-ভিত্তিক পদ্ধতি (BERTopic) শব্দ মেলানোর বদলে অর্থের কাছাকাছি থাকা ব্যবহার করে, তাই ছোট লেখায় অনেক সময় ভালো করে বলে জানা যায়, কিন্তু বাংলায় embedding-এর মান যাচাই করতে হয়।

বাজারঘরে আমি শুরু করতাম NMF আর LDA দুটোই কয়েকটি seed-এ চালিয়ে, তারপর একটি “এক রিভিউ এক বিষয়” model-এর সঙ্গে তুলনা করে, আর প্রতিটি পদ্ধতির topic-এর ২০টি ভারী রিভিউ পড়ে। যেটির topic দল পড়ে চিনতে পারে আর seed বদলালেও টেকে, সেটিই।

মূল উপলব্ধি: Model-এর অনুমান (নথিতে অনেক শব্দ, একাধিক topic) ডেটার সঙ্গে মেলে কি না, তা আগে দেখুন; না মিললে model বদলান বা ডেটার একক বদলান।

প্র ০৪বাংলা লেখার কোন বৈশিষ্ট্য topic model-এর ফল সবচেয়ে বেশি বদলে দেয়, আর প্রতিটির জন্য কী সাবধানতা নেবেন?

প্রথম স্তর Unicode. কার-চিহ্ন আর হসন্ত combining mark, তাই অনেক tool-এর ডিফল্ট “শব্দ” ধারণা বাংলায় ভাঙে; এই পর্বে default pattern “রাইডার”-কে “ইড” বানিয়েছে। “য়” এক code point (U+09DF) বা “য” + নুক্তা (U+09AF U+09BC) দুভাবেই লেখা যায়; চোখে এক, কম্পিউটারে দুই শব্দ। “র‍্যাব”-এর মতো রূপে zero-width joiner থাকে। তাই প্রথমে Unicode normalization (NFC), তারপর এমন tokenizer যা vocabulary-র নমুনা পড়ে যাচাই করা।

দ্বিতীয় স্তর রূপতত্ত্ব। বাংলায় বিভক্তি আর প্রত্যয় শব্দের সঙ্গে জুড়ে থাকে: ডেলিভারি, ডেলিভারিতে, ডেলিভারির, ডেলিভারিটা। Bag of words-এ এগুলো আলাদা কলাম, তাই একই বিষয়ের প্রমাণ ছড়িয়ে পড়ে। সাবধানে বাছা কয়েকটি suffix ছাঁটা সাহায্য করে, কিন্তু অতিরিক্ত ছাঁটলে আলাদা শব্দ এক হয়ে যায়; ছাঁটার আগে আর পরে topic তুলনা করুন। Character n-gram (TF-IDF-এ) আর NMF মিলিয়েও রূপভেদ কিছুটা সামলানো যায়।

তৃতীয় স্তর ভাষার মিশ্রণ আর অস্বীকার। বাস্তব রিভিউয়ে “delivery late”, “dam beshi” আর বাংলা হরফ পাশাপাশি থাকে; শুধু বাংলা block ধরলে Banglish পুরো হারায়। আর “না”, “নেই”, “পাইনি” প্রচলিত stopword তালিকায় থাকতে পারে, অথচ অভিযোগের অর্থ এগুলোতেই। নিজের stopword তালিকা ছোট রাখুন, প্রতিটি শব্দ বাদ দেওয়ার কারণ লিখে রাখুন।

মূল উপলব্ধি: বাংলায় topic-এর মান অনেকটাই ঠিক হয় model চালানোর আগে: normalization, tokenization আর stopword-এর প্রতিটি সিদ্ধান্ত vocabulary পড়ে যাচাই করা দরকার।

প্র ০৫Embedding আর LLM-এর যুগে LDA কি অপ্রয়োজনীয়? কখন কোনটি বাছবেন, আর LLM দিয়ে topic-এর নাম দেওয়ানোর ঝুঁকি কী?

BERTopic-এর মতো পদ্ধতি pretrained model-এর embedding ব্যবহার করে, তাই “দেরিতে” আর “লেট”-এর মতো ভিন্ন শব্দও কাছাকাছি আসতে পারে, যা bag of words পারে না। অনেক ব্যবহারকারী ছোট লেখায় এতে বেশি পড়ার মতো topic পান বলে জানান। কিন্তু খরচ আছে: embedding model চালাতে বেশি গণনা, ফল UMAP আর HDBSCAN-এর parameter আর seed-এ বদলায়, ডিফল্টে প্রতিটি লেখা একটিই topic পায় (মিশ্রণ নয়), আর বাংলায় embedding-এর মান model-ভেদে অনেক আলাদা। LDA আর NMF হালকা, দ্রুত, স্বচ্ছ, আর পুরো গণনা নিজের যন্ত্রে।

কখন কোনটি: অল্প সময়ে প্রথম ছবি পেতে আর স্বচ্ছতার দরকার হলে NMF বা LDA; প্রতিশব্দ-ভরা ছোট লেখা আর ভালো বাংলা embedding হাতে থাকলে embedding-ভিত্তিক পদ্ধতি; সময় বা metadata-র সঙ্গে topic-এর সম্পর্ক মাপতে (যেমন কোন জেলায় কোন অভিযোগ বাড়ছে) structural topic model-এর মতো সম্প্রসারণ। অনেক দল দুটি পদ্ধতি পাশাপাশি চালিয়ে শুধু যে topic দুটিতেই আসে তাকে বিশ্বাস করে।

LLM দিয়ে শীর্ষ শব্দ থেকে topic-এর নাম লেখানো সময় বাঁচায়, কিন্তু LLM মিশ্র topic-কেও আত্মবিশ্বাসী একটি নাম দেবে; অংশ ৫-এর “দাম + ভাঙা প্যাকেট” topic-কে সে হয়তো “মূল্য” বলবে। নাম যাচাই করতে হবে নমুনা রিভিউ পড়ে। আর আসল ক্রেতার লেখা বাইরের কোনো সেবায় পাঠানোর আগে গোপনীয়তার নিয়ম দেখতে হবে। এই বিষয়গুলো পরের NLP আর generative AI কোর্সে বিস্তারিত আসবে।

মূল উপলব্ধি: নতুন পদ্ধতি পুরোনোটিকে বাতিল করে না; প্রশ্ন, ডেটা, খরচ আর স্বচ্ছতার প্রয়োজন মিলিয়ে বাছুন, আর যে পদ্ধতিই নিন, topic-এর অর্থ মানুষের পড়া দিয়ে যাচাই করুন।

অনুশীলন

হিসাবগুলো খাতায় করুন, তারপর উত্তর মেলান। কোড চালানো ঐচ্ছিক।

  1. হাতে LDA-র হিসাব: K = ৫, α = ০.১. একটি রিভিউয়ে N = ৮টি শব্দ, আর Gibbs sampling-এর একটি অবস্থায় topic-গণনা n = (৫, ২, ১, ০, ০)। (ক) θk = (nk + α)/(N + Kα) দিয়ে θ বের করুন। (খ) φ1(দাম) = ০.৩০, φ2(দাম) = ০.০৫, φ3(দাম) = ০.০১, বাকি দুটিতে ০ হলে এই রিভিউয়ে p(দাম | d) কত? (গ) ৪০০ শব্দের একটি আলাদা রাখা সংগ্রহে মোট log-likelihood −১,২০০ হলে perplexity কত?

    (ক) হর N + Kα = ৮ + ০.৫ = ৮.৫. θ = (৫.১, ২.১, ১.১, ০.১, ০.১)/৮.৫ = (০.৬০০, ০.২৪৭, ০.১২৯, ০.০১২, ০.০১২); যোগফল ১. শূন্য গণনার topic-ও ০.০১২ পায়, কেবল prior থেকে। (খ) p(দাম | d) = ০.৬০০ × ০.৩০ + ০.২৪৭ × ০.০৫ + ০.১২৯ × ০.০১ = ০.১৮০ + ০.০১২৪ + ০.০০১৩ ≈ ০.১৯৪. (গ) প্রতি শব্দে গড় log-likelihood = −১,২০০/৪০০ = −৩; perplexity = e৩ ≈ ২০.০৯. অর্থাৎ model গড়ে প্রায় ২০টি সমান সম্ভাব্য শব্দের মধ্যে দোটানায়।

  2. সমালোচনা ও নকশা: একজন সহকর্মী বাজারঘরের ২,০০০টি বাংলা Facebook মন্তব্যে default CountVectorizer() আর একটি বড় ইংরেজি-বাংলা stopword তালিকা (যাতে “না”, “নেই” আছে) দিয়ে LDA চালালেন। K = ৫ থেকে ৫০-এর মধ্যে training perplexity সবচেয়ে কম যেখানে, সেই K = ৫০ বাছলেন, প্রতিটি topic-এর শীর্ষ তিন শব্দ দেখে নাম দিলেন, আর রিপোর্টে লিখলেন “২৮% ক্রেতা ডেলিভারি নিয়ে অসন্তুষ্ট”। কী কী সমস্যা, আর আপনি কীভাবে সাজাবেন?

    সমস্যা: (১) Default pattern কার-চিহ্নে শব্দ ভাঙে, vocabulary অর্থহীন টুকরোয় ভরা। (২) “না/নেই” বাদ পড়ায় অভিযোগ আর প্রশংসা এক হয়ে যায়। (৩) Training perplexity K বাড়লে প্রায় সবসময় কমে, তাই এটি প্রায় স্বয়ংক্রিয়ভাবে সবচেয়ে বড় K বাছে; ২,০০০টি ছোট মন্তব্যে ৫০টি topic-এর বেশিরভাগ হবে জগাখিচুড়ি। (৪) তিন শব্দ দেখে নাম অনুমান মাত্র। (৫) Topic অনুভূতি মাপে না, আর θ-এর গড় বা প্রধান topic-এর শতাংশ “ক্রেতার শতাংশ” নয়; একজন ক্রেতা অনেক মন্তব্য করতে পারেন, বেশিরভাগ ক্রেতা কিছুই লেখেন না।

    নতুন নকশা: NFC normalization, বাংলা ও Banglish ধরে এমন tokenizer, vocabulary পড়ে যাচাই; ছোট নিজস্ব stopword তালিকা, অস্বীকারের শব্দ রেখে। ২০% মন্তব্য আলাদা রেখে K = ৩–১৫-এ held-out perplexity আর প্রতিটি topic-এর coherence, কয়েকটি seed-এ; NMF-এর সঙ্গে তুলনা। প্রতিটি topic-এর ২০টি ভারী মন্তব্য পড়ে নাম। অসন্তুষ্টির দাবির জন্য হাতে label করা ৩০০টি মন্তব্যের নমুনা থেকে অনুপাত আর তার confidence interval, শুধু “মন্তব্যের মধ্যে” বলে।

  3. আউটপুট অনুমান করুন: চালানোর আগে লিখে ফেলুন নিচের কোড কী ছাপবে (scikit-learn 1.8).
    from sklearn.feature_extraction.text import CountVectorizer
    s = "দাম বেশি ছাড় পাইনি"
    print(CountVectorizer().build_analyzer()(s))
    v = CountVectorizer(token_pattern=r"[\u0980-\u09FF]+", stop_words=["আর"])
    v.fit(["দাম বেশি আর ছাড় পাইনি", "দাম কম"])
    print(v.get_feature_names_out())
    print(v.transform(["দাম দাম কম"]).toarray())
    ['ইন']
    ['কম' 'ছাড়' 'দাম' 'পাইনি' 'বেশি']
    [[1 0 2 0 0]]

    Default pattern পরপর দুটি word character চায়। “দাম”, “বেশি”, “ছাড়”-এ প্রতিটি অক্ষরের পরেই একটি কার-চিহ্ন, তাই কোনো দুই-অক্ষরের টুকরো নেই; “পাইনি”-তে শুধু “ইন”। বাংলা pattern আর “আর” বাদ দিয়ে vocabulary পাঁচটি শব্দ, Unicode code point-এর ক্রমে সাজানো (ক, ছ, দ, প, ব)। শেষ লাইনে প্রতিটি কলামে গণনা: কম ১, দাম ২। ডেস্কটপে scikit-learn 1.8.0-এ চালিয়ে মেলানো।

  4. UMass হাতে: একটি topic-এর শীর্ষ তিন শব্দ (ক্রমে) দাম, বেশি, কম. Training রিভিউয়ে নথি-গণনা: D(দাম) = ১০০, D(বেশি) = ৬০, D(কম) = ৪৫, D(দাম, বেশি) = ৫০, D(দাম, কম) = ৪০, D(বেশি, কম) = ২. UMass-এর যোগফল আর গড় বের করুন, আর কোন জোড়া স্কোর সবচেয়ে কমাল তা ব্যাখ্যা করুন।

    জোড়াগুলো (পরের শব্দ, আগের শব্দ): log((৫০ + ১)/১০০) = log ০.৫১ ≈ −০.৬৭৩; log((৪০ + ১)/১০০) = log ০.৪১ ≈ −০.৮৯২; log((২ + ১)/৬০) = log ০.০৫ ≈ −২.৯৯৬. যোগফল ≈ −৪.৫৬১, গড় ≈ −১.৫২০. সবচেয়ে বড় ক্ষতি “বেশি–কম” জোড়ায়: দুটি শব্দ প্রায় কখনো একই রিভিউতে আসে না, কারণ একজন হয় “দাম বেশি” লেখেন, নয় “দাম কম”। অথচ মানুষের চোখে তিনটিই এক “দাম” বিষয়। Co-occurrence-ভিত্তিক coherence বিপরীতার্থক শব্দের topic-কে শাস্তি দিতে পারে; সংখ্যা দেখে topic বাদ দেওয়ার আগে পড়ে দেখুন।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমবাংলা রিভিউয়ে topic modeling-এর ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. Unicode normalization আর বাংলা-উপযোগী tokenization, vocabulary-র নমুনা পড়ে যাচাই
  2. ছোট stopword তালিকা দিয়ে document-term matrix বানানো
  3. কিছু রিভিউ আলাদা রেখে কয়েকটি K আর seed-এ LDA বা NMF চালানো
  4. Held-out perplexity আর প্রতিটি topic-এর coherence মাপা
  5. শীর্ষ শব্দ আর সবচেয়ে ভারী রিভিউ পড়ে K চূড়ান্ত করা ও topic-এর নাম দেওয়া
  6. Topic-অনুপাত leakage-মুক্ত pipeline-এ ব্যবহার করে baseline-এর পাশে মাপা

LDA-তে document–topic prior α-কে ০.১-এর মতো ছোট রাখলে সাধারণত কী হয়?

  • প্রতিটি নথিতে সব topic প্রায় সমান ভাগ পায়
  • প্রতিটি নথি অল্প কয়েকটি topic-এ ভারী হয়
  • প্রতিটি topic অল্প কয়েকটি শব্দে ভারী হয়
  • Topic-এর সংখ্যা নিজে থেকে কমে যায়

একটি নথিতে θ = (০.৬, ০.৪), আর একটি শব্দ w-এর জন্য φ1(w) = ০.১, φ2(w) = ০.৩. LDA অনুযায়ী p(w | d) কত?

  • ০.৪০
  • ০.০৩
  • ০.১৮
  • ০.২০

scikit-learn-এর default CountVectorizer() বাংলা লেখায় “রাইডার ফোন ধরেননি”-কে “ইড ধর নন” বানায় কেন?

  • Lowercase করার সময় বাংলা অক্ষর বদলে যায়
  • ডিফল্ট stopword তালিকা বাংলা শব্দ বাদ দেয়
  • ফাইলের encoding UTF-8 নয়
  • কার-চিহ্ন আর হসন্ত regex-এর \w-এর মধ্যে পড়ে না, তাই শব্দ ভাঙে

একই TF-IDF matrix-এ NMF-এর topic সাধারণত LSA (TruncatedSVD)-এর দিকের চেয়ে পড়তে সহজ হয় কেন?

  • NMF-এর সব ওজন অঋণাত্মক আর দিকগুলো লম্ব হতে বাধ্য নয়
  • NMF-এর reconstruction ভুল সবসময় SVD-এর চেয়ে কম
  • NMF-এর ফল একক, seed-এ বদলায় না
  • NMF topic-এর ওপর Dirichlet prior বসায়

৯০০টি রিভিউয়ের ৬০০টিতে LDA শিখে বাকি ৩০০টিতে মাপা perplexity K = ২ থেকে ১০ পর্যন্ত কমতেই থাকে, অথচ শীর্ষ শব্দ পড়লে K = ৫-এর topic সবচেয়ে পরিষ্কার মনে হয়। কোন সিদ্ধান্ত সবচেয়ে যুক্তিসঙ্গত?

  • সবচেয়ে কম perplexity বলে K = ১০ নেওয়া
  • Coherence আর পড়ার ভিত্তিতে কাজের প্রয়োজন মিলিয়ে K বাছা, কয়েকটি seed-এ যাচাই করে
  • Training ডেটার perplexity দেখে সিদ্ধান্ত নেওয়া
  • Topic modeling বাতিল করা, কারণ মাপগুলো একমত নয়

রিভিউয়ের topic-অনুপাত θ দিয়ে “অভিযোগ কি না” অনুমানের classifier cross-validation-এ মাপতে চান। কোন পদ্ধতিটি সঠিক?

  • পুরো ডেটায় একবার LDA চালিয়ে θ বানানো, তারপর θ ভাগ করে cross-validation
  • সব রিভিউয়ে fit করে একই রিভিউয়ে accuracy মাপা
  • Vectorizer, LDA আর classifier এক pipeline-এ রেখে cross-validation, আর একটি TF-IDF baseline-এর পাশে তুলনা
  • Test fold-এ সবচেয়ে ভালো ফল দেয় এমন K বাছা

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. Topic = শব্দের সম্ভাবনা-বণ্টন φ, রিভিউ = topic-এর মিশ্রণ θ; p(w | d) = ∑k θdkφkw. বাংলায় আগে token_pattern ঠিক করুন, “না/নেই” রাখুন।
  2. LDA-র গল্প: φk ~ Dir(η), θd ~ Dir(α), প্রতিটি শব্দে z ~ θd, w ~ φz. α < ১ মানে অল্প কয়েকটি topic-এ ভারী রিভিউ। শেখা হয় Gibbs sampling বা variational inference দিয়ে; ফল seed-নির্ভর।
  3. θk ≈ (nk + α) / (N + Kα): ছোট রিভিউয়ে prior-এর ভাগ বড়, ভাগাভাগি শব্দ ভারী topic-এর দিকে যায়, আর topic-এর নাম আপনার দেওয়া অনুমান।
  4. LSA-তে ঋণাত্মক ওজন আর লম্ব দিক; TF-IDF-এ NMF অঋণাত্মক, পড়ার মতো topic দেয়; LDA prior আর অনিশ্চয়তা যোগ করে।
  5. K বাছাইয়ে held-out perplexity, coherence আর মানুষের পড়া তিনটি আলাদা সাক্ষী; topic-অনুপাত “কী নিয়ে” বলে, “কেমন লাগল” নয়, তাই feature হিসেবে baseline-এর পাশে আর leakage ছাড়া মাপুন।

পরের পর্বে: এই পর্বে Dirichlet prior আর posterior বারবার এসেছে, কিন্তু অনুমানের অনিশ্চয়তা আমরা প্রায় দেখিনি। মডিউল ৯ শুরু হবে পর্ব ৪৫ (Bayesian linear regression — posterior আর predictive অনিশ্চয়তা) দিয়ে: weight-এর ওপর prior, ডেটা দেখে posterior, আর নতুন বিন্দুতে অনুমানের সঙ্গে তার অনিশ্চয়তার পরিসর।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান