0

Xây dựng Sentiment Analysis tự học: Từ Manual Label đến Auto-Learning

Vấn đề: Vòng lặp không bao giờ kết thúc

Khi xây dựng hệ thống phân tích sentiment cho thị trường chứng khoán Việt Nam, chúng tôi rơi vào một vòng lặp quen thuộc:

  1. Thu thập dữ liệu → Crawl tin tức hàng ngày
  2. Gán nhãn thủ công → 3 người label từng tin (3-4 giờ/ngày)
  3. Train model → Accuracy ~75%
  4. Deploy → Gặp từ mới không có trong lexicon
  5. Quay lại bước 2 → Label thêm...

Vấn đề: Từ vựng tài chính Việt Nam thay đổi liên tục. "Bắt đáy" hôm nay là tích cực, ngày mai có thể là tiêu cực tùy ngữ cảnh. Một bộ lexicon cố định không thể theo kịp.


Giải pháp: Self-Learning Sentiment Pipeline

Chúng tôi xây dựng một pipeline zero-manual-approval với flow:

┌─────────────────────────────────────────────────────────────────┐
│                    AUTO-LEARNING FLOW                            │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  User Feedback (when |predicted - actual| > 0.3)                │
│       ↓                                                         │
│  ┌─────────────────┐     Extract n-grams (bigrams, trigrams)    │
│  │ keyword_suggestions│  → "lập kỷ lục", "phá sản",...          │
│  │   (SQLite DB)   │                                            │
│  └────────┬────────┘     Track: frequency, co-occurrence        │
│           ↓                                                     │
│  ┌─────────────────────────┐                                    │
│  │  Auto-Aggregate Engine  │  frequency ≥ 2 + confidence ≥ 0.3  │
│  │                         │  weight = f(freq, consensus)       │
│  └────────┬────────────────┘                                    │
│           ↓                                                     │
│  ┌─────────────────────────┐     Static Lexicon (117 pos terms) │
│  │  Sentiment Scoring      │  +  Auto Keywords (dynamic)        │
│  │  (_score_vietnamese)    │  → Real-time scoring               │
│  └─────────────────────────┘                                    │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

Architecture Deep Dive

1. Data Layer: SQLite với 3 bảng chính

-- Bảng 1: Lưu feedback từ users
CREATE TABLE sentiment_feedback (
    id INTEGER PRIMARY KEY,
    news_title TEXT,           -- "Vingroup lập kỷ lục lợi nhuận"
    predicted_score REAL,      -- 0.25 (Somewhat-Bullish)
    user_score REAL,           -- 0.60 (Bullish) ← Ground truth
    created_at TIMESTAMP
);

-- Bảng 2: Raw keywords extract (chưa qua xử lý)
CREATE TABLE keyword_suggestions (
    id INTEGER PRIMARY KEY,
    keyword TEXT,              -- "lập kỷ lục"
    sentiment_type TEXT,       -- 'positive' | 'negative'
    suggested_weight REAL,     -- 0.60
    co_occurrence_count INT,   -- Số lần xuất hiện
    supporting_titles TEXT,    -- JSON ["title1", "title2"]
    reviewed BOOLEAN DEFAULT 0 -- 0 = pending, 1 = processed
);

-- Bảng 3: (Optional) Manual overrides nếu cần
CREATE TABLE learned_keywords (
    keyword TEXT UNIQUE,
    sentiment_type TEXT,
    weight REAL,
    status TEXT -- 'approved' | 'rejected'
);

Key insight: Chúng tôi KHÔNG cần bảng learned_keywords nếu muốn 100% auto. Mọi thứ tính toán runtime từ keyword_suggestions.


2. Aggregation Engine: Tính toán weight động

Đây là phần quan trọng nhất. Thay vì hard-code weight, chúng tôi dùng frequency-based confidence scoring:

def get_auto_aggregated_keywords(
    min_confidence: float = 0.3,
    min_frequency: int = 2,
    lookback_days: int = 30
) -> Dict[str, Dict[str, float]]:
    """
    Tự động aggregate keywords từ raw suggestions.
    Không cần admin approve!
    """
    conn = sqlite3.connect(DB_PATH)
    cursor = conn.cursor()
    
    # Aggregate theo keyword + sentiment
    cursor.execute("""
        SELECT 
            keyword,
            sentiment_type,
            COUNT(*) as freq,                    -- Số lần xuất hiện
            AVG(suggested_weight) as avg_weight, -- Trung bình weight
            MAX(co_occurrence_count) as max_cooccur
        FROM keyword_suggestions
        WHERE created_at >= datetime('now', '-' || ? || ' days')
        GROUP BY keyword, sentiment_type
        HAVING freq >= ?                       -- Filter noise
    """, (lookback_days, min_frequency))
    
    results = {'positive': {}, 'negative': {}}
    
    for row in cursor.fetchall():
        keyword, sentiment_type, freq, avg_weight, max_cooccur = row
        
        # Công thức tính confidence
        # Càng nhiều lần xuất hiện → confidence càng cao
        confidence = min(1.0, freq / 10) * min(1.0, max_cooccur / 5)
        
        if confidence >= min_confidence:
            # Weight = base × confidence scaling, cap ở 0.8
            weight = min(0.8, avg_weight * (0.5 + confidence * 0.5))
            results[sentiment_type][keyword] = round(weight, 3)
    
    return results

Ví dụ thực tế:

Keyword Freq Confidence Final Weight
"lập kỷ lục" 8 0.64 0.54
"phá sản" 12 0.96 0.72
"tăng nhẹ" 2 0.20 → Bị loại (< 0.3)

3. Scoring Layer: Kết hợp Static + Dynamic

def _score_vietnamese(text: str) -> float:
    """
    Kết hợp lexicon cố định + auto-learned keywords.
    Cache 5 phút để optimize performance.
    """
    # 1. Load auto-learned (dynamic)
    auto_pos, auto_neg = _get_auto_learned_lexicons()  # Cache 5min
    
    # 2. Merge với static lexicon
    pos_lex = STATIC_POSITIVE + auto_pos  # List of (term, weight)
    neg_lex = STATIC_NEGATIVE + auto_neg
    
    # 3. Tính score
    lex_score = _lexicon_score(text, pos_lex, neg_lex)
    
    # 4. Blend với underthesea (nếu có)
    if _uts_available:
        direction = _uts_sentiment(text)
        return _blend_scores(lex_score, direction)
    
    return lex_score

Cache strategy quan trọng: Vì aggregate từ DB mỗi lần scoring sẽ chậm, chúng tôi cache kết quả 5 phút:

_auto_learned_cache: Optional[Tuple[List, List]] = None
_auto_learned_cache_ts: float = 0.0
_CACHE_TTL = 300.0  # 5 minutes

def _get_auto_learned_lexicons():
    global _auto_learned_cache, _auto_learned_cache_ts
    now = time.monotonic()
    
    if _auto_learned_cache and (now - _auto_learned_cache_ts) < _CACHE_TTL:
        return _auto_learned_cache
    
    # Re-compute từ DB
    keywords = SentimentLearningManager().get_auto_aggregated_keywords()
    _auto_learned_cache = (format_as_list(keywords['positive']), 
                           format_as_list(keywords['negative']))
    _auto_learned_cache_ts = now
    return _auto_learned_cache

Implementation Guide

Bước 1: Setup Database

# sentiment_learning.py
class SentimentLearningManager:
    def __init__(self, db_path="trend_news.db"):
        self.db_path = db_path
        self._init_tables()
    
    def _init_tables(self):
        conn = sqlite3.connect(self.db_path)
        conn.executescript("""
            CREATE TABLE IF NOT EXISTS sentiment_feedback (...);
            CREATE TABLE IF NOT EXISTS keyword_suggestions (...);
        """)
        conn.commit()

Bước 2: Hook vào Feedback Loop

def on_user_feedback(news_title: str, predicted: float, actual: float):
    """
    Hook này gọi khi user submit feedback (correct/incorrect)
    """
    manager = SentimentLearningManager()
    
    # Lưu feedback
    manager.add_feedback(
        news_title=news_title,
        predicted_score=predicted,
        user_score=actual
    )
    
    # Auto-extract nếu sai lệch lớn
    if abs(predicted - actual) > 0.3:
        manager._extract_keywords_from_feedback(
            news_title, actual
        )

Bước 3: Tích hợp vào Scoring

# sentiment.py
from sentiment_learning import SentimentLearningManager

def get_sentiment(text: str) -> Tuple[float, str]:
    # ... existing code ...
    
    # Thay vì chỉ dùng static lexicon
    # pos_lex = STATIC_POSITIVE
    
    # Giờ kết hợp với auto-learned
    auto_keywords = SentimentLearningManager().get_auto_aggregated_keywords()
    pos_lex = {**STATIC_POSITIVE, **auto_keywords['positive']}
    
    score = calculate_score(text, pos_lex, neg_lex)
    return score, label_from_score(score)

Monitoring & Debugging

Chúng tôi xây dựng một Streamlit dashboard để monitor:

# sentiment_dashboard.py
import streamlit as st

st.title("Sentiment Learning Dashboard")

# Stats
auto_keywords = learning_mgr.get_auto_aggregated_keywords()
st.metric("Auto Positive Keywords", len(auto_keywords['positive']))
st.metric("Auto Negative Keywords", len(auto_keywords['negative']))

# Review UI (pagination cho large dataset)
suggestions = learning_mgr.get_pending_suggestions_paginated(
    offset=page * 20,
    limit=20
)

for item in suggestions:
    col1, col2 = st.columns([4, 1])
    with col1:
        st.write(f"**{item['keyword']}** (freq: {item['frequency']})")
    with col2:
        if st.button("🗑️ Remove", key=item['id']):
            learning_mgr.reject_keyword(item['id'])
            st.rerun()

Key insight: Dù là auto-learning, vẫn cần giao diện để audit và remove keywords không phù hợp (ví dụ: từ viết tắt, nhiễu).


Kết quả sau 3 tháng triển khai

Metric Before After
Manual labeling time 4 giờ/ngày 30 phút/tuần (review only)
Lexicon size 206 terms 206 + 89 auto-learned
Accuracy on test set 74% 84%
New keywords discovered 0 15-20/tuần

Key Takeaways

  1. Feedback loop là vua: Không cần label tất cả, chỉ cần label những case model sai → extract pattern

  2. Frequency = Confidence: Từ xuất hiện nhiều lần tự động có weight cao, không cần hard-code

  3. Cache để scale: Aggregate từ DB mỗi request sẽ chậm, dùng 5-min cache

  4. Human-in-the-loop (optional): Auto 99%, nhưng vẫn cho phép admin remove nếu cần


Code Repository

Toàn bộ code mẫu có tại: https://github.com/phanngoc/agent-trading

pip install -r requirements.txt
streamlit run sentiment_dashboard.py

All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí