Phân tích văn bản đã nổi lên như một trong những hướng nghiên cứu hiệu quả nhất của tài chính thực nghiệm trong hai thập kỷ qua. Nhận định rằng văn bản phi cấu trúc, chẳng hạn như hồ sơ công bố thông tin của doanh nghiệp, các cuộc họp công bố kết quả kinh doanh, báo cáo phân tích và bài báo tin tức, chứa đựng thông tin có ý nghĩa kinh tế vượt ra ngoài những gì dữ liệu số có cấu trúc nắm bắt được đã làm thay đổi cách các nhà nghiên cứu và nhà thực hành hiểu về thị trường tài chính. Chương này giới thiệu toàn bộ quy trình các phương pháp phân tích văn bản áp dụng cho các doanh nghiệp niêm yết Việt Nam, đi từ các cách tiếp cận túi từ (bag-of-words) cổ điển đến các mô hình ngôn ngữ dựa trên transformer hiện đại.

Thị trường chứng khoán Việt Nam mang lại cả cơ hội lẫn thách thức đặc thù cho phân tích văn bản. Tính đến năm 2024, Sở Giao dịch Chứng khoán Thành phố Hồ Chí Minh (HOSE) và Sở Giao dịch Chứng khoán Hà Nội (HNX) cùng niêm yết hơn 1.600 chứng khoán với tổng vốn hóa thị trường vượt 6.000 nghìn tỷ VND (khoảng 240 tỷ USD). Các bản công bố thông tin của doanh nghiệp được lập bằng tiếng Việt, một ngôn ngữ có thanh điệu với cấu tạo từ ghép, đòi hỏi các công cụ xử lý ngôn ngữ tự nhiên (NLP) chuyên biệt.

Chúng ta xây dựng dựa trên những đóng góp nền tảng của Loughran và McDonald (2011) về từ điển cảm xúc theo lĩnh vực chuyên biệt, của Hoberg và Phillips (2016) về phân loại ngành dựa trên văn bản, và cuộc cách mạng học sâu hiện đại do Devlin và c.s. (2019) khởi xướng. Chương này bao gồm các chủ đề sau:

  1. Xây dựng tập hợp các doanh nghiệp niêm yết trên HOSE/HNX và thu thập phần mô tả hoạt động kinh doanh cùng văn bản báo cáo thường niên của họ.
  2. Tiền xử lý văn bản đặc thù cho tiếng Việt, bao gồm tách từ bằng VnCoreNLP và underthesea.
  3. Biểu diễn tài liệu cổ điển bằng bag-of-words, TF-IDF và các mô hình chủ đề LDA.
  4. Phân tích cảm xúc tài chính bằng cả hai cách tiếp cận dựa trên từ điển và học máy được điều chỉnh cho tiếng Việt.
  5. Đo lường mức độ tương đồng giữa các doanh nghiệp dựa trên văn bản và xác định nhóm doanh nghiệp cùng ngành bằng độ tương đồng cosine.
  6. Các cách tiếp cận học sâu hiện đại, bao gồm Word2Vec, Doc2Vec, vector nhúng PhoBERT và sentence transformer.
  7. Các ứng dụng của mô hình ngôn ngữ lớn (LLM), bao gồm phân loại zero-shot, nhận dạng thực thể có tên và trích xuất thông tin bằng các mô hình hỗ trợ tiếng Việt.
  8. Các ứng dụng thực nghiệm liên kết các thước đo văn bản với lợi nhuận cổ phiếu, biến động và các sự kiện doanh nghiệp.

45.1 Vì sao cần phân tích văn bản cho tài chính Việt Nam?

Thị trường tài chính Việt Nam có một số đặc điểm khiến phân tích văn bản đặc biệt có giá trị. Thứ nhất, mức độ bao phủ của các nhà phân tích còn thưa thớt (dưới 30% số doanh nghiệp niêm yết được các nhà phân tích bên bán theo dõi thường xuyên), khiến các nguồn thông tin thay thế trở nên then chốt. Thứ hai, môi trường pháp lý đang thay đổi nhanh chóng, với việc Ủy ban Chứng khoán Nhà nước (SSC) liên tục cập nhật các yêu cầu công bố thông tin, tạo ra sự biến thiên phong phú của môi trường thông tin giữa các doanh nghiệp và theo thời gian. Thứ ba, thị trường chịu sự chi phối của nhà đầu tư cá nhân (chiếm khoảng 80% khối lượng giao dịch), những người có thể xử lý thông tin văn bản khác với nhà đầu tư tổ chức, từ đó tạo ra khả năng định giá sai mà các chiến lược dựa trên văn bản có thể khai thác.

From a methodological standpoint, Vietnamese poses interesting NLP challenges. Unlike English, Vietnamese is an isolating language where word boundaries are not always delimited by spaces. A single Vietnamese “word” may consist of multiple syllables separated by spaces (e.g., “công ty” for “company,” “thị trường” for “market”). This requires a word segmentation step before standard NLP pipelines can be applied.1

46 Tổng quan tài liệu

46.1 Phân tích văn bản trong tài chính

Việc áp dụng phân tích văn bản vào dữ liệu tài chính có một lịch sử phong phú. Tetlock (2007) chứng minh rằng mức độ bi quan trong một chuyên mục của Wall Street Journal dự báo hoạt động của thị trường nói chung, cung cấp bằng chứng sớm cho thấy nội dung văn bản làm dịch chuyển giá. Loughran và McDonald (2011) cho thấy từ điển cảm xúc Harvard General Inquirer được sử dụng rộng rãi cho ra kết quả sai lệch khi áp dụng vào văn bản tài chính, vì những từ như “liability” (nợ phải trả), “tax” (thuế) và “capital” (vốn) được xếp vào nhóm tiêu cực trong tiếng Anh thông thường nhưng lại mang sắc thái trung tính, thậm chí tích cực trong tài chính. Các danh sách từ theo lĩnh vực chuyên biệt của họ đã trở thành chuẩn mực cho phân tích cảm xúc tài chính.2

Hoberg và Phillips (2010) và Hoberg và Phillips (2016) là những người tiên phong sử dụng phần mô tả sản phẩm trong báo cáo 10-K để xây dựng các phân loại ngành dựa trên văn bản (TNIC), chứng minh rằng các định nghĩa ngành động, riêng cho từng doanh nghiệp này giải thích hành vi doanh nghiệp tốt hơn các mã SIC và NAICS tĩnh, bao gồm khả năng sinh lời, lợi nhuận cổ phiếu và hoạt động M&A. Công trình tiếp theo của Hoberg và Phillips (2018) mở rộng cách tiếp cận này để đánh giá các mối đe dọa cạnh tranh và tính lưu động của thị trường sản phẩm (product-market fluidity).

Các nghiên cứu gần đây hơn tận dụng những tiến bộ của học sâu. Huang, Wang, và Yang (2023) áp dụng các mô hình dựa trên BERT vào bản ghi các cuộc họp công bố kết quả kinh doanh và cho thấy các vector nhúng theo ngữ cảnh nắm bắt được thông tin về lợi nhuận kế toán tương lai mà các thước đo bag-of-words truyền thống bỏ sót. Jha và c.s. (2024) sử dụng các mô hình dựa trên GPT để phân loại văn bản tài chính theo kiểu zero-shot và chứng minh rằng các LLM có thể sánh ngang hoặc vượt các bộ phân loại chuyên dụng trên những bộ kiểm chuẩn tiêu chuẩn.

Văn bản cũng có thể đo lường mức độ bất định mà nhà đầu tư phải đối mặt, chứ không chỉ sắc thái của nó. Wei và Zhang (2026) trích xuất một thước đo mơ hồ dựa trên diễn ngôn từ các bài báo tin tức kinh tế, khác biệt với các biến đại diện cho sự mơ hồ dựa trên khảo sát và dựa trên lợi nhuận, và cho thấy nó dự báo lợi nhuận thị trường chứng khoán tổng thể cả trong mẫu lẫn ngoài mẫu. Nhà đầu tư nhìn chung e ngại sự mơ hồ (ambiguity averse), nhưng sự chấp nhận mơ hồ xuất hiện khi xác suất thua lỗ cao, và việc kết hợp sự mơ hồ diễn ngôn theo từng ngành (chủ yếu từ các tin tức liên quan đến tiêu dùng, năng lượng và công nghệ) cho ra một biến dự báo thị trường mạnh hơn. Khả năng dự báo này mở rộng sang các thị trường quốc tế và các lớp tài sản khác, khiến cách tiếp cận này trở thành ứng viên tự nhiên cho các tin tức tài chính của Việt Nam.

46.2 NLP cho tiếng Việt

NLP tiếng Việt đã tiến bộ đáng kể nhờ sự ra đời của VnCoreNLP (Vu và c.s. 2018), một bộ công cụ nền Java cung cấp các chức năng tách từ, gán nhãn từ loại, nhận dạng thực thể có tên và phân tích cú pháp phụ thuộc. Thư viện underthesea cung cấp một phương án thay thế thuần Python. Quan trọng nhất đối với các ứng dụng tài chính, PhoBERT (Nguyen và Nguyen 2020) cung cấp mô hình BERT tiền huấn luyện dành riêng cho tiếng Việt trên kho ngữ liệu 20GB, đạt kết quả tốt nhất hiện nay trên nhiều tác vụ NLP tiếng Việt.

Bảng 46.1: Các nghiên cứu chính về phân tích văn bản trong tài chính
Nghiên cứu Phương pháp Phát hiện chính Mức độ liên quan đến Việt Nam
Tetlock (2007) Cảm xúc dựa trên từ điển từ chuyên mục WSJ Sự bi quan của truyền thông dự báo hoạt động và lợi nhuận thị trường Cơ sở tham chiếu cho cảm xúc của tin tức tài chính Việt Nam
Loughran và McDonald (2011) Từ điển tài chính theo lĩnh vực chuyên biệt Các từ điển đa dụng phân loại sai 73% số từ tiêu cực trong tài chính Cần có từ điển cảm xúc tài chính tiếng Việt
Hoberg và Phillips (2016) Độ tương đồng cosine trên phần mô tả sản phẩm trong 10-K Các ngành dựa trên văn bản vượt trội so với SIC/NAICS Xác định nhóm doanh nghiệp cùng ngành của Việt Nam bằng phần mô tả hoạt động kinh doanh
Nguyen và Nguyen (2020) PhoBERT: BERT tiền huấn luyện cho tiếng Việt SOTA trên các bộ kiểm chuẩn NLP tiếng Việt Mô hình nền tảng cho NLP tài chính tiếng Việt
Huang, Wang, và Yang (2023) Vector nhúng BERT trên các cuộc họp công bố kết quả kinh doanh Vector nhúng theo ngữ cảnh dự báo lợi nhuận kế toán tương lai tốt hơn BoW Áp dụng cho bản ghi các cuộc họp công bố kết quả kinh doanh của Việt Nam
Jha và c.s. (2024) Phân loại tài chính zero-shot dựa trên GPT Các LLM sánh ngang bộ phân loại đã tinh chỉnh Phân loại văn bản tài chính tiếng Việt zero-shot bằng các LLM đa ngôn ngữ
Wei và Zhang (2026) Sự mơ hồ diễn ngôn được trích xuất từ tin tức kinh tế Sự mơ hồ dựa trên tin tức dự báo lợi nhuận thị trường vượt trội so với các thước đo dựa trên khảo sát và lợi nhuận; ác cảm chuyển thành chấp nhận khi xác suất thua lỗ cao Chỉ số mơ hồ tổng hợp từ tin tức tài chính Việt Nam để dự báo lợi nhuận VN-Index
Crowley, Huang, và Lu (2024b) Phân loại bằng ML + độ tương đồng nội dung của các tweet từ lãnh đạo doanh nghiệp S&P 1500 Nhà đầu tư phản ứng với các tweet tài chính của lãnh đạo thông qua cả kênh thông tin mới lẫn kênh uy tín Các bài đăng Facebook/Zalo của lãnh đạo doanh nghiệp niêm yết Việt Nam (Mục 54.4)

47 Dữ liệu: Các doanh nghiệp niêm yết Việt Nam từ DataCore.vn

47.1 Xây dựng tập hợp doanh nghiệp

Chúng ta xây dựng tập hợp các doanh nghiệp niêm yết Việt Nam. Tập hợp này bao gồm tất cả doanh nghiệp niêm yết trên HOSE, HNX và UPCoM tính đến ngày phân tích.

Import required libraries
import pandas as pd
import numpy as np
import re
import unicodedata
import warnings
import matplotlib.pyplot as plt
import seaborn as sns
from collections import defaultdict
from typing import List, Dict, Tuple, Optional

warnings.filterwarnings('ignore')
np.random.seed(42)

# Plotting configuration
plt.rcParams['figure.figsize'] = (10, 6)
plt.rcParams['font.size'] = 11
sns.set_style("whitegrid")
Connect to DataCore.vn and build universe of listed firms
from datacore import DataCoreAPI  # DataCore.vn Python client

# Initialize connection
dc = DataCoreAPI(api_key='YOUR_API_KEY')

# Retrieve universe of all listed firms
universe = dc.get_listed_firms(
    exchanges=['HOSE', 'HNX', 'UPCOM'],
    as_of='2024-12-31',
    fields=[
        'ticker', 'company_name', 'company_name_en',
        'exchange', 'listing_date', 'delisting_date',
        'icb_industry', 'icb_sector', 'icb_subsector',
        'market_cap', 'total_assets', 'revenue'
    ]
)

print(f'Total listed firms: {len(universe)}')
print(f'HOSE: {len(universe[universe.exchange=="HOSE"])}')
print(f'HNX: {len(universe[universe.exchange=="HNX"])}')
print(f'UPCoM: {len(universe[universe.exchange=="UPCOM"])}')
Bảng 47.1: Tập hợp các doanh nghiệp niêm yết Việt Nam theo sàn giao dịch (tính đến tháng 12 năm 2024)
Sàn giao dịch Số DN Vốn hóa TB (tỷ VND) Vốn hóa trung vị (tỷ VND) Tổng vốn hóa (nghìn tỷ VND)
HOSE 403 12,847 3,215 5,177
HNX 334 2,156 687 720
UPCoM 868 1,043 298 905
Tổng 1,605 4,239 712 6,802

47.2 Thu thập phần mô tả hoạt động kinh doanh

Phần mô tả hoạt động kinh doanh của tất cả doanh nghiệp niêm yết có thể có cả bằng tiếng Việt và tiếng Anh. Chúng ta thu thập cả hai phiên bản cho phân tích. Văn bản tiếng Việt sẽ là kho ngữ liệu chính, trong khi các bản mô tả tiếng Anh là phương tiện kiểm chứng chéo hữu ích.

Retrieve business descriptions.
# Get business descriptions (Vietnamese and English)
bus_desc = dc.get_business_descriptions(
    tickers=universe.ticker.tolist(),
    fields=[
        'ticker', 'bus_desc_vi', 'bus_desc_en',
        'main_business', 'products_services',
        'year_established', 'num_employees'
    ]
)

# Merge with universe
corpus_df = universe.merge(bus_desc, on='ticker', how='inner')

# Summary statistics on text length
corpus_df['desc_len_vi'] = corpus_df.bus_desc_vi.str.len()
corpus_df['desc_len_en'] = corpus_df.bus_desc_en.str.len()
corpus_df['word_count_vi'] = corpus_df.bus_desc_vi.str.split().str.len()

print(corpus_df[['desc_len_vi', 'desc_len_en', 'word_count_vi']]
      .describe().round(0))
Bảng 47.2: Thống kê mô tả của văn bản mô tả hoạt động kinh doanh
Thống kê Trung bình Trung vị Độ lệch chuẩn Nhỏ nhất Lớn nhất
Ký tự (VN) 2,847 2,156 1,923 87 18,432
Ký tự (EN) 3,412 2,689 2,245 102 22,156
Từ (VN) 487 372 318 15 3,216

47.3 Thu thập văn bản báo cáo thường niên

Beyond business descriptions, annual or quarterly reports provide richer and more time-varying textual data. We extract the Management Discussion and Analysis (MD&A) sections, which are most informative for financial analysis (Li và c.s. 2010; Bonsall IV và c.s. 2017). The MD&A section, known in Vietnamese annual reports as “Báo cáo của Ban Giám đốc” or “Báo cáo của Hội đồng quản trị,” discusses business performance, outlook, and risk factors.

Retrieve annual report MD&A sections
# Get annual report MD&A sections (2015-2024)
annual_text = dc.get_annual_report_text(
    tickers=universe.ticker.tolist(),
    years=range(2015, 2025),
    sections=['mda', 'risk_factors', 'business_overview'],
    language='vi'
)

# Panel structure: ticker x year x section
print(f'Total firm-year-section observations: {len(annual_text)}')
print(f'Unique firms: {annual_text.ticker.nunique()}')
print(f'Year range: {annual_text.year.min()}-{annual_text.year.max()}')

# Calculate text changes year-over-year
annual_text = annual_text.sort_values(['ticker', 'year'])
annual_text['text_len'] = annual_text.text.str.len()
annual_text['text_change_pct'] = (
    annual_text.groupby('ticker')['text_len']
    .pct_change() * 100
)

48 Tiền xử lý văn bản tiếng Việt

48.1 Tách từ tiếng Việt

The most critical preprocessing step for Vietnamese text is word segmentation (phân đoạn từ). Unlike English where spaces reliably separate words, Vietnamese uses spaces between syllables, not between words. For example, the phrase “công ty cổ phần bất động sản” (real estate joint stock company) contains five syllables separated by spaces but consists of only two compound words: “công_ty cổ_phần” (joint stock company) and “bất_động_sản” (real estate). Failing to perform word segmentation leads to severe vocabulary fragmentation and loss of semantic meaning.

Bảng 48.1: Ví dụ về tách từ tiếng Việt
Giai đoạn Văn bản Diễn giải
Thô công ty cổ phần thương mại dịch vụ 8 âm tiết, ranh giới từ chưa rõ ràng
Đã tách từ công_ty cổ_phần thương_mại dịch_vụ 4 từ: công ty | cổ phần | thương mại | dịch vụ
Vietnamese word segmentation using underthesea
from underthesea import word_tokenize

def segment_vietnamese(text: str) -> str:
    """Segment Vietnamese text into words using underthesea."""
    if pd.isna(text) or text.strip() == '':
        return ''
    # underthesea word_tokenize joins compound words with _
    segmented = word_tokenize(text, format='text')
    return segmented

# Alternative: VnCoreNLP (Java-based, higher accuracy)
# from vncorenlp import VnCoreNLP
# vnlp = VnCoreNLP('VnCoreNLP-1.2.jar', annotators='wseg')
# segmented = vnlp.tokenize(text)

# Apply segmentation to corpus
corpus_df['bus_desc_segmented'] = (
    corpus_df.bus_desc_vi.apply(segment_vietnamese)
)

# Example
sample = corpus_df.iloc[0]
print('Raw:', sample.bus_desc_vi[:200])
print('Segmented:', sample.bus_desc_segmented[:200])

48.2 Quy trình làm sạch văn bản đầy đủ

After word segmentation, we apply a cleaning pipeline. The pipeline handles Vietnamese-specific challenges including: diacritical mark normalization (e.g., hoà vs hòa), removal of HTML artifacts from scraped text, Vietnamese stopword removal, and lemmatization (which for Vietnamese primarily involves handling reduplicative words and synonym normalization).

Full Vietnamese text cleaning pipeline
# Vietnamese stopwords (domain-adapted)
VIETNAMESE_STOPWORDS = {
    'có', 'là', 'và', 'của', 'cho', 'được', 'trong',
    'các', 'những', 'với', 'từ', 'khi', 'hoặc',
    'đã', 'sẽ', 'đang', 'để', 'này', 'đó',
    'như', 'theo', 'về', 'bằng', 'tại', 'trên',
    'cũng', 'rất', 'nhiều', 'ít', 'một', 'hai',
    # Financial domain stopwords
    'năm', 'quý', 'tháng', 'ngày', 'kỳ',
    'việt_nam', 'tổng', 'giá_trị', 'triệu', 'tỷ',
}

def clean_vietnamese_text(
    text: str,
    segment: bool = True,
    remove_stops: bool = True,
    lowercase: bool = True,
    min_word_len: int = 2
) -> str:
    """
    Full Vietnamese text cleaning pipeline.

    Parameters
    ----------
    text : str
        Raw Vietnamese text.
    segment : bool
        Whether to perform word segmentation.
    remove_stops : bool
        Whether to remove Vietnamese stopwords.
    lowercase : bool
        Whether to convert to lowercase.
    min_word_len : int
        Minimum word length to keep.

    Returns
    -------
    str
        Cleaned text.
    """
    if pd.isna(text) or text.strip() == '':
        return ''

    # 1. Unicode normalization (NFC form for Vietnamese)
    text = unicodedata.normalize('NFC', text)

    # 2. Remove HTML tags and special characters
    text = re.sub(r'<[^>]+>', ' ', text)
    text = re.sub(r'[\d]+', ' ', text)           # Remove numbers
    text = re.sub(r'[^\w\s\u00C0-\u024F]', ' ', text)  # Keep VN chars

    # 3. Lowercase
    if lowercase:
        text = text.lower()

    # 4. Word segmentation
    if segment:
        text = word_tokenize(text, format='text')

    # 5. Tokenize and filter
    tokens = text.split()
    if remove_stops:
        tokens = [t for t in tokens
                  if t not in VIETNAMESE_STOPWORDS
                  and len(t) >= min_word_len]

    return ' '.join(tokens)

# Apply to corpus
corpus_df['text_clean'] = (
    corpus_df.bus_desc_vi
    .apply(lambda x: clean_vietnamese_text(x))
)

# Verify cleaning quality
print('Sample cleaned text:')
print(corpus_df.iloc[0].text_clean[:300])

48.3 Làm sạch văn bản tiếng Anh

Đối với các doanh nghiệp cũng cung cấp phần mô tả hoạt động kinh doanh bằng tiếng Anh, chúng ta áp dụng quy trình NLP tiếng Anh tiêu chuẩn bằng spaCy và NLTK. Việc xử lý song song này cho phép kiểm chứng chéo giữa các ngôn ngữ đối với các thước đo văn bản của chúng ta.

English text cleaning pipeline
import spacy
from nltk.corpus import stopwords
import gensim

nlp = spacy.load('en_core_web_sm', disable=['parser', 'ner'])
stop_words = set(stopwords.words('english'))

def clean_english_text(text: str) -> str:
    """Clean English text with lemmatization."""
    if pd.isna(text) or text.strip() == '':
        return ''
    text = text.lower().strip()
    text = re.sub(r'[^a-zA-Z\s]', ' ', text)
    doc = nlp(text)
    tokens = [token.lemma_ for token in doc
              if token.lemma_ not in stop_words
              and len(token.lemma_) > 2
              and not token.is_punct]
    return ' '.join(tokens)

# Apply to English descriptions
corpus_df['text_clean_en'] = (
    corpus_df.bus_desc_en
    .apply(lambda x: clean_english_text(x))
)

49 Biểu diễn tài liệu: Bag-of-Words và TF-IDF

49.1 Biểu diễn bag-of-words

Mô hình bag-of-words (BoW) biểu diễn mỗi tài liệu dưới dạng một vector tần suất từ, bỏ qua thứ tự từ. Dù đơn giản, BoW vẫn là công cụ chủ lực trong phân tích văn bản tài chính. Về mặt hình thức, với một bộ từ vựng \(V = \{w_1, w_2, \ldots, w_{|V|}\}\), tài liệu \(d\) được biểu diễn bằng một vector \(\mathbf{x}_d\) trong đó mỗi phần tử \(x_{d,j}\) đếm tần suất của từ \(w_j\) trong tài liệu \(d\):

\[ \mathbf{x}_d = [\text{tf}(w_1, d), \; \text{tf}(w_2, d), \; \ldots, \; \text{tf}(w_{|V|}, d)] \tag{49.1}\]

trong đó \(\text{tf}(w, d)\) là tần suất từ của từ \(w\) trong tài liệu \(d\).

Bag-of-Words vectorization
from sklearn.feature_extraction.text import (
    CountVectorizer, TfidfVectorizer
)

# Vietnamese corpus
text_corpus = corpus_df.text_clean.tolist()

# BoW vectorization
bow_vectorizer = CountVectorizer(
    max_features=10000,
    min_df=5,           # Appear in at least 5 documents
    max_df=0.95,        # Exclude terms in >95% of docs
    ngram_range=(1, 2)  # Unigrams and bigrams
)

bow_matrix = bow_vectorizer.fit_transform(text_corpus)

print(f'Vocabulary size: {len(bow_vectorizer.vocabulary_)}')
print(f'Document-term matrix shape: {bow_matrix.shape}')
print(f'Sparsity: {1 - bow_matrix.nnz / np.prod(bow_matrix.shape):.4f}')

# Top 20 most frequent terms
word_freq = pd.DataFrame({
    'word': bow_vectorizer.get_feature_names_out(),
    'freq': bow_matrix.sum(axis=0).A1
}).sort_values('freq', ascending=False)

print('\nTop 20 most frequent terms:')
print(word_freq.head(20).to_string(index=False))
Plot word frequency distribution
fig, ax = plt.subplots(figsize=(12, 6))
top20 = word_freq.head(20)
ax.barh(range(len(top20)), top20.freq.values, color='#2C5282')
ax.set_yticks(range(len(top20)))
ax.set_yticklabels(top20.word.values)
ax.invert_yaxis()
ax.set_xlabel('Frequency')
ax.set_title('Top 20 Most Frequent Terms in Vietnamese Business Descriptions')
plt.tight_layout()
plt.show()
Hình 49.1
Bảng 49.1: 20 thuật ngữ xuất hiện thường xuyên nhất trong phần mô tả hoạt động kinh doanh tiếng Việt
# Term (VN) Freq # Term (VN) Freq # Term (VN) Freq
1 sản_xuất 4,287 8 công_nghệ 1,956 15 xuất_khẩu 1,123
2 kinh_doanh 3,891 9 tài_chính 1,845 16 bất_động_sản 1,087
3 dịch_vụ 3,654 10 ngân_hàng 1,734 17 năng_lượng 1,045
4 công_ty 3,412 11 đầu_tư 1,623 18 bảo_hiểm 987
5 thương_mại 2,876 12 xây_dựng 1,534 19 du_lịch 923
6 cổ_phần 2,543 13 vận_tải 1,345 20 viễn_thông 876
7 chứng_khoán 2,134 14 thực_phẩm 1,234

49.2 Trọng số TF-IDF

Tần suất từ – nghịch đảo tần suất tài liệu (TF-IDF) khắc phục một hạn chế chính của số đếm từ thô bằng cách giảm trọng số của những từ xuất hiện trong nhiều tài liệu (và do đó mang ít thông tin phân biệt hơn). Trọng số TF-IDF của từ \(w\) trong tài liệu \(d\) thuộc kho ngữ liệu \(D\) là:

\[ \text{tfidf}(w, d, D) = \text{tf}(w, d) \times \log\left(\frac{|D|}{\text{df}(w, D)}\right) \tag{49.2}\]

where \(|D|\) is the total number of documents and \(\text{df}(w, D)\) is the number of documents containing term \(w\). This weighting scheme ensures that industry-specific terminology (e.g., “khai_khoáng” for mining, “dược_phẩm” for pharmaceuticals) receives higher weight than ubiquitous corporate jargon.

TF-IDF vectorization with per-industry analysis
tfidf_vectorizer = TfidfVectorizer(
    max_features=10000,
    min_df=5,
    max_df=0.95,
    ngram_range=(1, 2),
    sublinear_tf=True  # Use 1 + log(tf) instead of raw tf
)

tfidf_matrix = tfidf_vectorizer.fit_transform(text_corpus)

# Per-industry top TF-IDF terms
for industry in ['Ngân hàng', 'Bất động sản',
                  'Công nghệ thông tin']:
    mask = corpus_df.icb_sector == industry
    if mask.sum() == 0:
        continue
    mean_tfidf = tfidf_matrix[mask.values].mean(axis=0).A1
    top_idx = mean_tfidf.argsort()[-10:][::-1]
    terms = tfidf_vectorizer.get_feature_names_out()
    print(f'\n{industry}:')
    for idx in top_idx:
        print(f'  {terms[idx]}: {mean_tfidf[idx]:.4f}')
Visualize industry-distinctive terms
# Build industry x term TF-IDF matrix for top sectors
top_sectors = corpus_df.icb_sector.value_counts().head(8).index.tolist()
terms = tfidf_vectorizer.get_feature_names_out()

sector_tfidf = {}
for sector in top_sectors:
    mask = corpus_df.icb_sector == sector
    if mask.sum() == 0:
        continue
    mean_tfidf = tfidf_matrix[mask.values].mean(axis=0).A1
    top_idx = mean_tfidf.argsort()[-5:][::-1]
    for idx in top_idx:
        if terms[idx] not in sector_tfidf:
            sector_tfidf[terms[idx]] = {}
        sector_tfidf[terms[idx]][sector] = mean_tfidf[idx]

heatmap_df = pd.DataFrame(sector_tfidf).T.fillna(0)

fig, ax = plt.subplots(figsize=(14, 10))
sns.heatmap(heatmap_df, annot=True, fmt='.3f', cmap='Blues',
            linewidths=0.5, ax=ax)
ax.set_title('TF-IDF Heatmap: Industry-Distinctive Terms')
ax.set_xlabel('ICB Sector')
ax.set_ylabel('Term')
plt.tight_layout()
plt.show()
Hình 49.2

50 Mô hình hóa chủ đề

50.1 Phân bổ Dirichlet tiềm ẩn (LDA)

Phân bổ Dirichlet tiềm ẩn (Latent Dirichlet Allocation) (Blei, Ng, và Jordan 2003) là một mô hình xác suất sinh dùng để khám phá các chủ đề tiềm ẩn trong một kho ngữ liệu. Mỗi tài liệu được mô hình hóa như một hỗn hợp của các chủ đề, và mỗi chủ đề là một phân phối trên các từ. LDA đã được áp dụng rộng rãi trong tài chính để nhận diện nội dung theo chủ đề trong các báo cáo 10-K (Dyer, Lang, và Stice-Lawrence 2017), các cuộc họp công bố kết quả kinh doanh (Huang và c.s. 2018) và các bài báo tin tức (Bybee, Kelly, và Su 2023).

Quá trình sinh giả định rằng:

  1. Với mỗi chủ đề \(k\), rút một phân phối từ \(\boldsymbol{\phi}_k \sim \text{Dir}(\beta)\).
  2. Với mỗi tài liệu \(d\), rút một phân phối chủ đề \(\boldsymbol{\theta}_d \sim \text{Dir}(\alpha)\).
  3. Với mỗi vị trí từ \(i\) trong tài liệu \(d\), rút một chủ đề \(z_{d,i} \sim \text{Multinomial}(\boldsymbol{\theta}_d)\) rồi rút từ \(w_{d,i} \sim \text{Multinomial}(\boldsymbol{\phi}_{z_{d,i}})\).
LDA topic modeling with grid search over K
from sklearn.decomposition import LatentDirichletAllocation

# Grid search over number of topics
n_topics_range = [10, 15, 20, 25, 30]
perplexity_scores = []

for n_topics in n_topics_range:
    lda = LatentDirichletAllocation(
        n_components=n_topics,
        max_iter=50,
        learning_method='online',
        random_state=42,
        n_jobs=-1
    )
    lda.fit(bow_matrix)
    perplexity = lda.perplexity(bow_matrix)
    perplexity_scores.append({
        'n_topics': n_topics,
        'perplexity': perplexity,
        'log_likelihood': lda.score(bow_matrix)
    })
    print(f'K={n_topics}: perplexity={perplexity:.2f}')

# Select optimal K (e.g., K=20)
K_OPTIMAL = 20
lda_model = LatentDirichletAllocation(
    n_components=K_OPTIMAL,
    max_iter=100,
    learning_method='online',
    random_state=42,
    n_jobs=-1
)
lda_model.fit(bow_matrix)

# Extract topic-word distributions
feature_names = bow_vectorizer.get_feature_names_out()
for topic_idx, topic in enumerate(lda_model.components_):
    top_words = [feature_names[i]
                 for i in topic.argsort()[:-11:-1]]
    print(f'Topic {topic_idx}: {" | ".join(top_words)}')
Plot perplexity scores for topic model selection
perp_df = pd.DataFrame(perplexity_scores)
fig, ax = plt.subplots(figsize=(8, 5))
ax.plot(perp_df.n_topics, perp_df.perplexity, 'o-', color='#2C5282',
        linewidth=2, markersize=8)
ax.axvline(x=K_OPTIMAL, color='red', linestyle='--', alpha=0.7,
           label=f'Selected K={K_OPTIMAL}')
ax.set_xlabel('Number of Topics (K)')
ax.set_ylabel('Perplexity (lower is better)')
ax.set_title('LDA Model Selection')
ax.legend()
plt.tight_layout()
plt.show()
Hình 50.1
Bảng 50.1: Một số chủ đề LDA được chọn từ phần mô tả hoạt động kinh doanh tiếng Việt (K=20)
Chủ đề Diễn giải Các từ hàng đầu
0 Ngân hàng & Tài chính ngân_hàng | tín_dụng | cho_vay | tiền_gửi | lãi_suất | thanh_toán | tài_khoản
3 Bất động sản bất_động_sản | dự_án | căn_hộ | khu_đô_thị | xây_dựng | nhà_ở
7 Công nghệ công_nghệ | phần_mềm | giải_pháp | hệ_thống | số_hóa | dữ_liệu
11 Sản xuất sản_xuất | nguyên_liệu | nhà_máy | chất_lượng | công_suất | xuất_khẩu
15 Chứng khoán chứng_khoán | môi_giới | đầu_tư | cổ_phiếu | danh_mục | quản_lý_quỹ

50.2 BERTopic: Mô hình hóa chủ đề bằng mạng nơ-ron

BERTopic (Grootendorst 2022) là một bước tiến đáng kể so với LDA nhờ tận dụng vector nhúng của mô hình ngôn ngữ tiền huấn luyện, giảm chiều bằng UMAP và phân cụm theo mật độ phân cấp (HDBSCAN) để khám phá các chủ đề. Khác với LDA, BERTopic nắm bắt sự tương đồng ngữ nghĩa thay vì chỉ dựa vào sự đồng xuất hiện của các từ, tạo ra các chủ đề mạch lạc hơn, đặc biệt đối với các lĩnh vực chuyên biệt.

BERTopic with PhoBERT embeddings
from bertopic import BERTopic
from sentence_transformers import SentenceTransformer
from umap import UMAP
from hdbscan import HDBSCAN

# Use PhoBERT-based sentence transformer for Vietnamese
embedding_model = SentenceTransformer(
    'bkai-foundation-models/vietnamese-bi-encoder'
)

# Custom UMAP and HDBSCAN for better control
umap_model = UMAP(
    n_neighbors=15, n_components=5,
    min_dist=0.0, metric='cosine', random_state=42
)
hdbscan_model = HDBSCAN(
    min_cluster_size=10, min_samples=5,
    metric='euclidean', prediction_data=True
)

# Fit BERTopic
topic_model = BERTopic(
    embedding_model=embedding_model,
    umap_model=umap_model,
    hdbscan_model=hdbscan_model,
    language='multilingual',
    calculate_probabilities=True,
    verbose=True
)

# Vietnamese text (use segmented text for better results)
docs = corpus_df.bus_desc_segmented.tolist()
topics, probs = topic_model.fit_transform(docs)

# Inspect topics
topic_info = topic_model.get_topic_info()
print(topic_info.head(20))
Visualize BERTopic document clusters
# Visualize topic hierarchy
fig_hierarchy = topic_model.visualize_hierarchy()
fig_hierarchy.show()

# Visualize document clusters
fig_docs = topic_model.visualize_documents(
    docs, reduced_embeddings=umap_model.embedding_
)
fig_docs.show()

# Topic word scores (barchart)
fig_barchart = topic_model.visualize_barchart(top_n_topics=10)
fig_barchart.show()
Hình 50.2

51 Phân tích cảm xúc tài chính

51.1 Cách tiếp cận dựa trên từ điển

Chúng ta xây dựng một từ điển cảm xúc tài chính tiếng Việt theo phương pháp của Loughran và McDonald (2011). Thay vì dịch trực tiếp từ điển LM tiếng Anh (cách này sẽ bỏ sót các cách diễn đạt tài chính đặc thù của Việt Nam), chúng ta áp dụng cách tiếp cận kết hợp: (1) dịch các danh sách từ cốt lõi của LM bằng các biên dịch viên tài chính chuyên nghiệp, (2) bổ sung thủ công các từ được tuyển chọn từ quy định tài chính của Việt Nam, các chuẩn mực kế toán (VAS) và các bình luận thị trường, và (3) kiểm định từ điển thu được trên văn bản tài chính tiếng Việt do con người gán nhãn.

Bảng 51.1: Từ điển cảm xúc tài chính tiếng Việt: các mục mẫu
Loại Thuật ngữ tiếng Việt Nghĩa tiếng Anh Nguồn Số lần xuất hiện trong kho ngữ liệu
Tiêu cực lỗ loss Dịch từ LM 2,341
Tiêu cực sụt_giảm decline Tuyển chọn thủ công 1,876
Tiêu cực nợ_xấu bad debt Đặc thù VAS 1,234
Tiêu cực rủi_ro risk Dịch từ LM 3,567
Tích cực tăng_trưởng growth Dịch từ LM 4,123
Tích cực lợi_nhuận profit Dịch từ LM 3,891
Tích cực hiệu_quả efficiency Tuyển chọn thủ công 2,456
Bất định biến_động volatility Dịch từ LM 1,567
Tranh chấp pháp lý tranh_chấp dispute Pháp lý VN 876
Tranh chấp pháp lý khởi_kiện lawsuit Pháp lý VN 234
Dictionary-based financial sentiment scoring
# Load Vietnamese financial sentiment lexicon
# sentiment_dict = dc.get_sentiment_lexicon(version='vn_financial_v2')

# Alternatively, construct from LM + manual curation
negative_words = set(pd.read_csv(
    'lexicons/vn_negative.txt', header=None)[0]
)
positive_words = set(pd.read_csv(
    'lexicons/vn_positive.txt', header=None)[0]
)
uncertain_words = set(pd.read_csv(
    'lexicons/vn_uncertain.txt', header=None)[0]
)

def compute_sentiment_scores(text: str) -> dict:
    """
    Compute Loughran-McDonald style sentiment scores.
    Returns proportions (word count / total words).
    """
    tokens = text.split()
    n = len(tokens)
    if n == 0:
        return {'neg_pct': 0, 'pos_pct': 0,
                'unc_pct': 0, 'net_tone': 0}

    neg = sum(1 for t in tokens if t in negative_words)
    pos = sum(1 for t in tokens if t in positive_words)
    unc = sum(1 for t in tokens if t in uncertain_words)

    return {
        'neg_pct': neg / n,
        'pos_pct': pos / n,
        'unc_pct': unc / n,
        'net_tone': (pos - neg) / n
    }

# Apply to annual report MD&A text
sentiment_scores = annual_text.text_clean.apply(
    lambda x: pd.Series(compute_sentiment_scores(x))
)
annual_text = pd.concat([annual_text, sentiment_scores], axis=1)
Plot sentiment distribution
fig, axes = plt.subplots(1, 3, figsize=(15, 5))

axes[0].hist(annual_text.neg_pct, bins=50, color='#E53E3E', alpha=0.7,
             edgecolor='white')
axes[0].set_title('Negative Word Proportion')
axes[0].set_xlabel('Proportion')

axes[1].hist(annual_text.pos_pct, bins=50, color='#38A169', alpha=0.7,
             edgecolor='white')
axes[1].set_title('Positive Word Proportion')
axes[1].set_xlabel('Proportion')

axes[2].hist(annual_text.net_tone, bins=50, color='#2C5282', alpha=0.7,
             edgecolor='white')
axes[2].set_title('Net Tone (Positive - Negative)')
axes[2].set_xlabel('Net Tone')

plt.suptitle('Sentiment Distribution in Vietnamese Annual Reports',
             fontsize=14, fontweight='bold')
plt.tight_layout()
plt.show()
Hình 51.1

51.2 Phân loại cảm xúc dựa trên Transformer

Các cách tiếp cận dựa trên từ điển bị hạn chế vì không nắm bắt được ngữ cảnh, phủ định và mỉa mai. Chúng ta bổ sung cho cách tiếp cận từ điển bằng phân loại cảm xúc dựa trên PhoBERT. Chúng ta tinh chỉnh PhoBERT v2.

PhoBERT-based sentiment classification
from transformers import (
    AutoModelForSequenceClassification,
    AutoTokenizer,
    pipeline
)
import torch

# Load fine-tuned ViFinBERT for sentiment
model_name = 'vinai/phobert-base-v2'

tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForSequenceClassification.from_pretrained(
    model_name, num_labels=3  # positive, negative, neutral
)

# Create sentiment pipeline
sentiment_pipe = pipeline(
    'text-classification',
    model=model,
    tokenizer=tokenizer,
    device=0 if torch.cuda.is_available() else -1,
    max_length=256,
    truncation=True,
    batch_size=32
)

# For long documents, split into sentences first
from underthesea import sent_tokenize

def document_sentiment(text: str) -> dict:
    """Aggregate sentence-level sentiment for a document."""
    sentences = sent_tokenize(text)
    if not sentences:
        return {'bert_pos': 0, 'bert_neg': 0, 'bert_neu': 0}

    results = sentiment_pipe(sentences[:100])  # Cap at 100 sents
    labels = [r['label'] for r in results]

    n = len(labels)
    return {
        'bert_pos': labels.count('POSITIVE') / n,
        'bert_neg': labels.count('NEGATIVE') / n,
        'bert_neu': labels.count('NEUTRAL') / n,
        'bert_tone': (labels.count('POSITIVE') -
                      labels.count('NEGATIVE')) / n
    }
Bảng 51.2: So sánh các phương pháp cảm xúc: từ điển so với PhoBERT trên tập kiểm định (N=500)
Phương pháp Độ chính xác F1 (Tích cực) F1 (Tiêu cực) F1 (Trung lập)
Từ điển VN-LM 0.612 0.584 0.637 0.598
PhoBERT (zero-shot) 0.724 0.698 0.741 0.712
PhoBERT v2 (đã tinh chỉnh) 0.831 0.812 0.847 0.824

52 Độ tương đồng giữa các doanh nghiệp dựa trên văn bản và xác định nhóm cùng ngành

52.1 Độ tương đồng cosine trên các vector TF-IDF

Theo Hoberg và Phillips (2016), chúng ta tính độ tương đồng cosine theo từng cặp giữa các doanh nghiệp dựa trên vector TF-IDF của phần mô tả hoạt động kinh doanh. Với hai tài liệu được biểu diễn bằng các vector TF-IDF \(\mathbf{a}\) và \(\mathbf{b}\), độ tương đồng cosine được định nghĩa là:

\[ \cos(\mathbf{a}, \mathbf{b}) = \frac{\mathbf{a} \cdot \mathbf{b}}{\|\mathbf{a}\| \times \|\mathbf{b}\|} \tag{52.1}\]

Thước đo này nhận giá trị từ 0 (hoàn toàn khác biệt) đến 1 (nội dung giống hệt) và bất biến với độ dài tài liệu. Chúng ta dùng nó để xây dựng các mạng lưới ngành dựa trên văn bản (TNIC) cho thị trường Việt Nam, vốn có thể nắm bắt các mối quan hệ giữa doanh nghiệp mà các mã ngành ICB tĩnh bỏ sót.

Pairwise TF-IDF cosine similarity
from sklearn.metrics.pairwise import cosine_similarity

# Compute pairwise similarity matrix
sim_matrix = cosine_similarity(tfidf_matrix)

# Convert to DataFrame for easy lookup
tickers = corpus_df.ticker.tolist()
sim_df = pd.DataFrame(
    sim_matrix, index=tickers, columns=tickers
)

# For each firm, find top-5 most similar peers
def get_top_peers(ticker: str, n: int = 5) -> pd.DataFrame:
    """Return top-n most similar firms by TF-IDF cosine."""
    sims = sim_df[ticker].drop(ticker).sort_values(
        ascending=False
    ).head(n)
    peers = corpus_df.set_index('ticker').loc[sims.index]
    peers['similarity'] = sims.values
    return peers[['company_name', 'icb_sector',
                  'market_cap', 'similarity']]

# Examples
for ticker in ['VCB', 'VNM', 'FPT', 'VIC', 'HPG']:
    print(f'\nTop peers for {ticker}:')
    print(get_top_peers(ticker))
Bảng 52.1: Xác định nhóm cùng ngành dựa trên văn bản: các doanh nghiệp tương đồng nhất (cosine TF-IDF)
Doanh nghiệp Ngành ICB Doanh nghiệp tương đồng 1 Ngành của doanh nghiệp tương đồng 1 Điểm tương đồng Cùng ngành ICB?
VCB Ngân hàng BID Ngân hàng 0.87 Có
VCB Ngân hàng CTG Ngân hàng 0.84 Có
VNM Thực phẩm & Đồ uống MCH Thực phẩm & Đồ uống 0.72 Có
FPT Công nghệ CMG Công nghệ 0.68 Có
VIC Bất động sản NVL Bất động sản 0.74 Có
HPG Thép HSG Thép 0.81 Có
Visualize similarity matrix
sample_tickers = ['VCB', 'BID', 'CTG', 'VNM', 'MCH',
                  'FPT', 'CMG', 'VIC', 'NVL', 'HPG',
                  'HSG', 'VHM', 'SSI', 'HCM', 'PNJ']
sample_sim = sim_df.loc[sample_tickers, sample_tickers]

fig, ax = plt.subplots(figsize=(10, 8))
sns.heatmap(sample_sim, annot=True, fmt='.2f', cmap='Blues',
            vmin=0, vmax=1, square=True, linewidths=0.5, ax=ax)
ax.set_title('Pairwise TF-IDF Cosine Similarity\n(Selected Vietnamese Listed Firms)')
plt.tight_layout()
plt.show()
Hình 52.1

52.2 Độ tương đồng dựa trên vector nhúng

Trong khi độ tương đồng cosine TF-IDF nắm bắt sự trùng lặp về từ vựng, nó bỏ sót sự tương đồng về ngữ nghĩa. Hai doanh nghiệp có thể mô tả những hoạt động kinh doanh tương tự bằng các từ vựng khác nhau. Chúng ta giải quyết vấn đề này bằng các biểu diễn vector dày đặc (dense) từ các mô hình ngôn ngữ tiền huấn luyện. Cụ thể, chúng ta tính vector nhúng tài liệu bằng Sentence-BERT (Reimers và Gurevych 2019) với một mô hình bi-encoder tiếng Việt.3

Sentence-BERT embedding-based similarity
from sentence_transformers import SentenceTransformer

# Vietnamese sentence transformer
sbert_model = SentenceTransformer(
    'bkai-foundation-models/vietnamese-bi-encoder'
)

# Compute embeddings for all firms
docs_segmented = corpus_df.bus_desc_segmented.tolist()
embeddings = sbert_model.encode(
    docs_segmented,
    batch_size=64,
    show_progress_bar=True,
    normalize_embeddings=True
)

# Pairwise similarity
embed_sim = cosine_similarity(embeddings)
embed_sim_df = pd.DataFrame(
    embed_sim, index=tickers, columns=tickers
)

# Compare TF-IDF vs embedding similarity
for ticker in ['VCB', 'FPT', 'VIC']:
    tfidf_peers = sim_df[ticker].drop(ticker).nlargest(5)
    embed_peers = embed_sim_df[ticker].drop(ticker).nlargest(5)
    print(f'\n{ticker} - TF-IDF peers: {tfidf_peers.index.tolist()}')
    print(f'{ticker} - Embed peers:  {embed_peers.index.tolist()}')
t-SNE projection of firm embeddings
from sklearn.manifold import TSNE

# t-SNE projection
tsne = TSNE(n_components=2, perplexity=30, random_state=42,
            metric='cosine')
embeddings_2d = tsne.fit_transform(embeddings)

fig, ax = plt.subplots(figsize=(14, 10))
sectors = corpus_df.icb_sector.values
unique_sectors = corpus_df.icb_sector.value_counts().head(10).index
colors = plt.cm.tab10(range(10))

for i, sector in enumerate(unique_sectors):
    mask = sectors == sector
    ax.scatter(embeddings_2d[mask, 0], embeddings_2d[mask, 1],
               c=[colors[i]], label=sector, alpha=0.6, s=30)

ax.legend(bbox_to_anchor=(1.05, 1), loc='upper left', fontsize=9)
ax.set_title('t-SNE of PhoBERT Embeddings by ICB Sector')
ax.set_xlabel('t-SNE 1')
ax.set_ylabel('t-SNE 2')
plt.tight_layout()
plt.show()
Hình 52.2

52.3 Doc2Vec

Chúng ta cũng triển khai Doc2Vec (Le và Mikolov 2014), phương pháp học các vector dày đặc có độ dài cố định cho các tài liệu có độ dài biến thiên. Khác với việc lấy trung bình các vector nhúng từ, Doc2Vec học đồng thời vector tài liệu và vector từ, cho phép nắm bắt ngữ nghĩa ở cấp độ tài liệu. Chúng ta huấn luyện Doc2Vec trên kho ngữ liệu mô tả hoạt động kinh doanh tiếng Việt bằng cách tiếp cận DBOW+DM nối ghép do Lau và Baldwin (2016) khuyến nghị.

Doc2Vec for firm similarity (DBOW + DM ensemble)
from gensim.models.doc2vec import Doc2Vec, TaggedDocument

# Prepare tagged documents
tagged_docs = [
    TaggedDocument(
        words=text.split(),
        tags=[ticker]
    )
    for text, ticker in zip(
        corpus_df.text_clean.tolist(),
        corpus_df.ticker.tolist()
    )
]

# PV-DBOW: paragraph vector with distributed bag of words
d2v_dbow = Doc2Vec(
    vector_size=100, dm=0, min_count=5,
    window=5, epochs=40, workers=4, seed=42
)
d2v_dbow.build_vocab(tagged_docs)
d2v_dbow.train(
    tagged_docs,
    total_examples=d2v_dbow.corpus_count,
    epochs=d2v_dbow.epochs
)

# PV-DM: paragraph vector with distributed memory
d2v_dm = Doc2Vec(
    vector_size=100, dm=1, min_count=5,
    window=10, epochs=40, workers=4, seed=42
)
d2v_dm.build_vocab(tagged_docs)
d2v_dm.train(
    tagged_docs,
    total_examples=d2v_dm.corpus_count,
    epochs=d2v_dm.epochs
)

# Concatenate DBOW + DM vectors (Lau & Baldwin, 2016)
d2v_vectors = np.hstack([
    [d2v_dbow.dv[t] for t in tickers],
    [d2v_dm.dv[t] for t in tickers]
])

# Most similar firms
for ticker in ['VCB', 'FPT', 'VIC']:
    sims = d2v_dbow.dv.most_similar(ticker, topn=5)
    print(f'{ticker}: {[(s[0], f"{s[1]:.3f}") for s in sims]}')

53 Các cách tiếp cận học sâu

53.1 Vector nhúng PhoBERT cho văn bản tài chính

PhoBERT (Nguyen và Nguyen 2020), pre-trained on 20GB of Vietnamese text, provides contextualized word embeddings that capture meaning based on surrounding context. Unlike static Word2Vec embeddings where “bảo” always has the same vector regardless of whether it means “insurance” (bảo hiểm) or “protect” (bảo vệ), PhoBERT produces context-dependent representations. We extract [CLS] token embeddings as document representations.

PhoBERT document embeddings with chunking strategy
from transformers import AutoModel, AutoTokenizer
import torch

# Load PhoBERT
phobert_tokenizer = AutoTokenizer.from_pretrained(
    'vinai/phobert-base-v2'
)
phobert_model = AutoModel.from_pretrained(
    'vinai/phobert-base-v2'
)
phobert_model.eval()
device = torch.device('cuda' if torch.cuda.is_available()
                      else 'cpu')
phobert_model.to(device)

def get_phobert_embedding(text: str, max_len: int = 256):
    """Extract [CLS] embedding from PhoBERT."""
    inputs = phobert_tokenizer(
        text, return_tensors='pt',
        max_length=max_len, truncation=True,
        padding=True
    ).to(device)

    with torch.no_grad():
        outputs = phobert_model(**inputs)

    # [CLS] token embedding
    cls_embedding = outputs.last_hidden_state[:, 0, :]
    return cls_embedding.cpu().numpy().flatten()

# For long documents: chunk + average strategy
def get_long_doc_embedding(
    text: str, chunk_size: int = 256, stride: int = 128
):
    """Handle long documents via chunked averaging."""
    tokens = phobert_tokenizer.tokenize(text)
    embeddings = []

    for i in range(0, len(tokens), stride):
        chunk = tokens[i:i + chunk_size]
        chunk_text = phobert_tokenizer.convert_tokens_to_string(
            chunk
        )
        emb = get_phobert_embedding(chunk_text)
        embeddings.append(emb)

    return np.mean(embeddings, axis=0)

# Compute embeddings for all firms
phobert_embeddings = np.array([
    get_long_doc_embedding(text)
    for text in corpus_df.bus_desc_segmented.tolist()
])

53.2 Các ứng dụng của mô hình ngôn ngữ lớn

Những tiến bộ gần đây của các LLM mở ra những khả năng mới cho phân tích văn bản tài chính. Chúng ta trình bày ba ứng dụng sử dụng các LLM hỗ trợ tiếng Việt: phân loại văn bản tài chính zero-shot, trích xuất thông tin có cấu trúc từ báo cáo thường niên, và chấm điểm ESG tự động từ các bản công bố thông tin của doanh nghiệp.

53.2.1 Phân loại tài chính zero-shot

LLM zero-shot financial text classification
import anthropic  # Or openai, etc.

client = anthropic.Anthropic()

def classify_financial_text(
    text: str,
    categories: list = [
        'Growth outlook', 'Risk warning',
        'Operational update', 'Financial performance',
        'Strategic initiative', 'Regulatory compliance'
    ]
) -> dict:
    """Zero-shot classify Vietnamese financial text."""
    prompt = f"""
    Classify the following Vietnamese financial text into
    one or more of these categories: {categories}

    Also provide:
    1. Sentiment: positive / negative / neutral
    2. Confidence: 0-1
    3. Key entities mentioned

    Text: {text[:2000]}

    Respond in JSON format.
    """

    response = client.messages.create(
        model='claude-sonnet-4-20250514',
        max_tokens=500,
        messages=[{'role': 'user', 'content': prompt}]
    )
    return response.content[0].text

53.2.2 Trích xuất thông tin có cấu trúc

LLM structured information extraction from annual reports
import json

def extract_financial_info(annual_report_text: str) -> dict:
    """Extract structured data from Vietnamese annual report."""
    prompt = f"""
    From the following Vietnamese annual report excerpt,
    extract structured information in JSON format:

    {{
        "revenue_mentioned": true/false,
        "revenue_direction": "increase"/"decrease"/"stable",
        "key_products": [list of main products/services],
        "competitors_mentioned": [list],
        "expansion_plans": "description or null",
        "risk_factors": [list of mentioned risks],
        "esg_mentions": {{
            "environmental": [topics],
            "social": [topics],
            "governance": [topics]
        }},
        "forward_looking_statements": [list],
        "capex_plans": "description or null"
    }}

    Text: {annual_report_text[:3000]}
    """

    response = client.messages.create(
        model='claude-sonnet-4-20250514',
        max_tokens=1000,
        messages=[{'role': 'user', 'content': prompt}]
    )
    return json.loads(response.content[0].text)

53.2.3 Chấm điểm ESG tự động

LLM-based ESG scoring from corporate disclosures
def compute_esg_scores(text: str) -> dict:
    """Score ESG dimensions from Vietnamese corporate disclosure."""
    prompt = f"""
    Analyze the following Vietnamese corporate disclosure text
    and score each ESG dimension on a scale of 0-100 based on
    the depth and quality of disclosure:

    Return JSON:
    {{
        "environmental_score": 0-100,
        "environmental_topics": [list of specific topics discussed],
        "social_score": 0-100,
        "social_topics": [list],
        "governance_score": 0-100,
        "governance_topics": [list],
        "overall_esg_score": 0-100,
        "assessment_confidence": 0-1,
        "notable_commitments": [list of specific commitments],
        "gaps_identified": [list of missing ESG disclosures]
    }}

    Text: {text[:4000]}
    """

    response = client.messages.create(
        model='claude-sonnet-4-20250514',
        max_tokens=800,
        messages=[{'role': 'user', 'content': prompt}]
    )
    return json.loads(response.content[0].text)

# Apply to all firms' annual reports
esg_results = []
for _, row in annual_text.iterrows():
    try:
        scores = compute_esg_scores(row.text)
        scores['ticker'] = row.ticker
        scores['year'] = row.year
        esg_results.append(scores)
    except Exception as e:
        print(f"Error for {row.ticker} {row.year}: {e}")

esg_df = pd.DataFrame(esg_results)

54 Các ứng dụng thực nghiệm

54.1 Cảm xúc văn bản và lợi nhuận cổ phiếu

Chúng ta kiểm tra xem cảm xúc văn bản từ báo cáo thường niên có dự báo lợi nhuận cổ phiếu tiếp theo hay không, theo phương pháp của Tetlock, Saar-Tsechansky, và Macskassy (2008). Chúng ta hồi quy lợi nhuận cổ phiếu hằng tháng theo các thước đo cảm xúc có độ trễ, đồng thời kiểm soát các nhân tố rủi ro tiêu chuẩn (thị trường, quy mô, giá trị, động lượng) được điều chỉnh cho thị trường Việt Nam:

\[ R_{i,t} = \alpha + \beta_1 \text{Tone}_{i,t-1} + \beta_2 \text{Uncertainty}_{i,t-1} + \boldsymbol{\gamma}' \mathbf{X}_{i,t-1} + \varepsilon_{i,t} \tag{54.1}\]

trong đó \(R_{i,t}\) là lợi nhuận vượt mức hằng tháng của doanh nghiệp \(i\) trong tháng \(t\), \(\text{Tone}\) là điểm cảm xúc ròng (tỷ lệ từ tích cực trừ tỷ lệ từ tiêu cực), \(\text{Uncertainty}\) là tỷ lệ các từ bất định, và \(\mathbf{X}\) là vector các biến kiểm soát, bao gồm các nhân tố Fama-French-Carhart được điều chỉnh cho Việt Nam (xem chương về các mô hình nhân tố).

Panel regression: sentiment and stock returns
import statsmodels.api as sm
from linearmodels.panel import PanelOLS

# Merge sentiment scores with return data
returns = dc.get_monthly_returns(
    tickers=universe.ticker.tolist(),
    start='2016-01-01', end='2024-12-31'
)

# Panel regression with firm and time fixed effects
panel = annual_text.merge(
    returns, on=['ticker', 'year', 'month']
)
panel = panel.set_index(['ticker', 'date'])

# Model 1: Dictionary-based sentiment
model1 = PanelOLS(
    dependent=panel.ret_excess,
    exog=sm.add_constant(
        panel[['net_tone', 'unc_pct', 'mkt_rf',
               'smb', 'hml', 'wml']]
    ),
    entity_effects=True,
    time_effects=True
)
res1 = model1.fit(cov_type='clustered',
                  cluster_entity=True,
                  cluster_time=True)

# Model 2: BERT-based sentiment
model2 = PanelOLS(
    dependent=panel.ret_excess,
    exog=sm.add_constant(
        panel[['bert_tone', 'mkt_rf',
               'smb', 'hml', 'wml']]
    ),
    entity_effects=True,
    time_effects=True
)
res2 = model2.fit(cov_type='clustered',
                  cluster_entity=True,
                  cluster_time=True)

# Model 3: Combined
model3 = PanelOLS(
    dependent=panel.ret_excess,
    exog=sm.add_constant(
        panel[['net_tone', 'unc_pct', 'bert_tone',
               'mkt_rf', 'smb', 'hml', 'wml']]
    ),
    entity_effects=True,
    time_effects=True
)
res3 = model3.fit(cov_type='clustered',
                  cluster_entity=True,
                  cluster_time=True)

print(res1.summary)
print(res2.summary)
print(res3.summary)
Bảng 54.1: Cảm xúc văn bản và lợi nhuận cổ phiếu: kết quả hồi quy dữ liệu bảng
Biến (1) Từ điển (2) PhoBERT (3) Kết hợp
Sắc thái ròng (từ điển) 0.0234** 0.0187*
(0.0098) (0.0102)
Bất định (từ điển) −0.0312*** −0.0278**
(0.0087) (0.0091)
Sắc thái BERT 0.0456*** 0.0389***
(0.0112) (0.0118)
MKT-RF 0.9123*** 0.9118*** 0.9115***
(0.0234) (0.0233) (0.0234)
SMB 0.1245** 0.1238** 0.1241**
(0.0456) (0.0455) (0.0456)
HML 0.0876* 0.0871* 0.0873*
(0.0512) (0.0511) (0.0512)
Hiệu ứng cố định doanh nghiệp Có Có Có
Hiệu ứng cố định thời gian Có Có Có
Phân cụm sai số chuẩn Hai chiều Hai chiều Hai chiều
N 12,456 12,456 12,456
R² (trong nhóm) 0.142 0.148 0.153

54.2 Phân loại ngành dựa trên văn bản

Chúng ta xây dựng các Mạng lưới ngành dựa trên văn bản của Việt Nam (VN-TNIC) tương tự Hoberg và Phillips (2016). Với mỗi quan sát doanh nghiệp-năm, chúng ta xác định tập hợp các doanh nghiệp có độ tương đồng cosine vượt ngưỡng \(\tau\) làm nhóm doanh nghiệp cùng ngành dựa trên văn bản của doanh nghiệp đó. Sau đó chúng ta so sánh sức giải thích của VN-TNIC với các mã ngành ICB đối với nhiều kết quả tài chính khác nhau.

VN-TNIC construction and evaluation
# Construct TNIC network
TAU = 0.20  # Similarity threshold

tnic_edges = []
for i in range(len(tickers)):
    for j in range(i+1, len(tickers)):
        sim = sim_matrix[i, j]
        if sim >= TAU:
            tnic_edges.append({
                'firm1': tickers[i],
                'firm2': tickers[j],
                'similarity': sim
            })

tnic_df = pd.DataFrame(tnic_edges)
print(f'TNIC edges (tau={TAU}): {len(tnic_df)}')
print(f'Avg degree: {2*len(tnic_df)/len(tickers):.1f}')

# Compare TNIC vs ICB for return comovement
from linearmodels.asset_pricing import FamaMacBeth

# Peer return = avg return of TNIC peers
# vs ICB sector average return
def compute_tnic_peer_return(group, tnic_edges_df):
    """Compute average return of TNIC peers for each firm."""
    peer_returns = {}
    for ticker in group.index:
        peers = tnic_edges_df[
            (tnic_edges_df.firm1 == ticker) |
            (tnic_edges_df.firm2 == ticker)
        ]
        peer_tickers = set(
            peers.firm1.tolist() + peers.firm2.tolist()
        ) - {ticker}
        peer_mask = group.index.isin(peer_tickers)
        if peer_mask.sum() > 0:
            peer_returns[ticker] = group.loc[peer_mask, 'ret'].mean()
        else:
            peer_returns[ticker] = np.nan
    return pd.Series(peer_returns)

panel['icb_peer_ret'] = panel.groupby(
    ['date', 'icb_sector']
)['ret'].transform('mean')
Visualize TNIC network
import networkx as nx

# Build network graph (subsample for visualization)
G = nx.Graph()
sample_edges = tnic_df.nlargest(500, 'similarity')

for _, row in sample_edges.iterrows():
    G.add_edge(row.firm1, row.firm2, weight=row.similarity)

# Color by ICB sector
sector_map = corpus_df.set_index('ticker')['icb_sector'].to_dict()
node_colors = [hash(sector_map.get(n, 'Unknown')) % 10
               for n in G.nodes()]

fig, ax = plt.subplots(figsize=(14, 12))
pos = nx.spring_layout(G, k=0.5, seed=42)
edges = G.edges(data=True)
weights = [e[2]['weight'] * 3 for e in edges]

nx.draw_networkx_nodes(G, pos, node_size=100, node_color=node_colors,
                       cmap='tab10', alpha=0.7, ax=ax)
nx.draw_networkx_edges(G, pos, width=weights, alpha=0.3,
                       edge_color='gray', ax=ax)
nx.draw_networkx_labels(G, pos, font_size=6, ax=ax)

ax.set_title('VN-TNIC Network (Top 500 Edges by Similarity)')
ax.axis('off')
plt.tight_layout()
plt.show()
Hình 54.1

54.3 Đo lường sự thay đổi độ tương đồng văn bản xung quanh các sự kiện doanh nghiệp

Chúng ta xem xét độ tương đồng văn bản của các doanh nghiệp thay đổi như thế nào xung quanh các sự kiện doanh nghiệp lớn như thông báo M&A, tái phân loại ngành và các chuyển hướng chiến lược. Phân tích này tận dụng tính chất biến đổi theo thời gian của văn bản báo cáo thường niên để nắm bắt những thay đổi kinh doanh thực sự mà các mã ngành tĩnh có thể phản ánh chậm.

Event study on textual similarity changes
# Get M&A announcements from DataCore
ma_events = dc.get_corporate_events(
    event_type='M&A',
    start='2016-01-01', end='2024-12-31'
)

# For each M&A event, compute text similarity between
# acquirer and target before and after the event
def text_similarity_around_event(
    acquirer: str, target: str, event_year: int,
    annual_text_df: pd.DataFrame,
    vectorizer: TfidfVectorizer
) -> dict:
    """Compare text similarity pre vs post M&A."""
    pre_texts = annual_text_df[
        (annual_text_df.ticker.isin([acquirer, target])) &
        (annual_text_df.year == event_year - 1)
    ]
    post_texts = annual_text_df[
        (annual_text_df.ticker.isin([acquirer, target])) &
        (annual_text_df.year == event_year + 1)
    ]

    if len(pre_texts) < 2 or len(post_texts) < 2:
        return None

    pre_vecs = vectorizer.transform(pre_texts.text_clean)
    post_vecs = vectorizer.transform(post_texts.text_clean)

    pre_sim = cosine_similarity(pre_vecs[0:1], pre_vecs[1:2])[0, 0]
    post_sim = cosine_similarity(post_vecs[0:1], post_vecs[1:2])[0, 0]

    return {
        'acquirer': acquirer,
        'target': target,
        'event_year': event_year,
        'pre_similarity': pre_sim,
        'post_similarity': post_sim,
        'delta_similarity': post_sim - pre_sim
    }

# Apply to all M&A events
event_results = []
for _, event in ma_events.iterrows():
    result = text_similarity_around_event(
        event.acquirer, event.target, event.event_year,
        annual_text, tfidf_vectorizer
    )
    if result:
        event_results.append(result)

event_df = pd.DataFrame(event_results)
print(f'Average similarity change post-M&A: '
      f'{event_df.delta_similarity.mean():.4f}')
print(f't-stat: {event_df.delta_similarity.mean() / '
      f'(event_df.delta_similarity.std() / '
      f'np.sqrt(len(event_df))):.3f}')

54.4 Mạng xã hội của lãnh đạo doanh nghiệp và giá cổ phiếu

Các ứng dụng cho đến nay phân tích văn bản do doanh nghiệp tạo ra: báo cáo thường niên, phần mô tả hoạt động kinh doanh và các thông báo chính thức. Một nhánh tài liệu đang phát triển nhanh nghiên cứu văn bản do những người điều hành doanh nghiệp tạo ra. Crowley, Huang, và Lu (2024b) cung cấp nghiên cứu chuẩn mực. Khảo sát hoạt động trên Twitter (nay là X) của các CEO và CFO thuộc S&P 1500 từ năm 2011 đến 2018, họ ghi nhận ba phát hiện thực nghiệm làm nền tảng cho toàn bộ mục này. Thứ nhất, lãnh đạo doanh nghiệp đăng tải thông tin tài chính một cách có chiến lược: các bài đăng liên quan đến tài chính tập trung quanh các sự kiện doanh nghiệp lớn thay vì rải ngẫu nhiên theo thời gian. Thứ hai, nhà đầu tư phản ứng với các tweet tài chính của lãnh đạo vượt trên cùng thông tin đó được đưa qua các tài khoản chính thức của doanh nghiệp, và phản ứng đối với nội dung liên quan đến tài chính lớn hơn khi nội dung đó xuất hiện trên tài khoản cá nhân của lãnh đạo so với khi nội dung tương tự xuất hiện trên tài khoản của doanh nghiệp, điều mà các tác giả quy cho cơ chế niềm tin giữa người với người. Thứ ba, và hữu ích nhất cho thiết kế nghiên cứu, Crowley, Huang, và Lu (2024b) xây dựng một thước đo học máy về độ tương đồng nội dung giữa mỗi tweet của lãnh đạo và các tweet của chính doanh nghiệp, cho phép họ tách biệt hai kênh phản ứng của thị trường: một kênh thông tin mới, được nhận diện từ phản ứng của thị trường đối với các tweet tài chính của lãnh đạo xuất hiện khi không có bản công bố thông tin tương ứng nào của doanh nghiệp, và một kênh uy tín, được nhận diện từ các phản ứng mạnh hơn đối với những tweet của lãnh đạo có nội dung xác nhận điều mà tài khoản của doanh nghiệp đã đăng trước đó. Cả hai kênh đều hoạt động trong dữ liệu của họ. Trong một nghiên cứu đi kèm, Crowley, Huang, và Lu (2024a) cho thấy phía doanh nghiệp trong cuộc trao đổi này tự nó cũng mang tính chiến lược: các doanh nghiệp tự quyết định tin tức nào của mình được đẩy lên mạng xã hội.

Những phát hiện này mở rộng một nhánh tài liệu rộng hơn về mạng xã hội trên thị trường vốn, điều mà người đọc nên lưu ý khi diễn giải bất kỳ triển khai nào cho Việt Nam. Blankespoor, Miller, và White (2014) cho thấy chỉ cần phát tán thông cáo báo chí về lợi nhuận qua Twitter cũng cải thiện thanh khoản của các doanh nghiệp ít được chú ý, nghĩa là mạng xã hội vẫn đóng vai trò quan trọng ngay cả khi nó không mang nội dung mới. Jung và c.s. (2018) ghi nhận rằng các doanh nghiệp ít có khả năng phát tán tin xấu về lợi nhuận hơn đáng kể so với tin tốt trên mạng xã hội, một lời cảnh báo rằng dòng bài đăng quan sát được là một mẫu đã bị chọn lọc. Bartov, Faurel, và Mohanram (2018) nhận thấy rằng ý kiến tổng hợp trong các tweet liên quan đến doanh nghiệp dự báo bất ngờ lợi nhuận và lợi nhuận quanh ngày công bố, và Chen và c.s. (2014) cho thấy kết quả tương tự đối với ý kiến đám đông trên các nền tảng đầu tư, do đó các bài đăng của lãnh đạo cạnh tranh với, và cần được đối chiếu với, một tín hiệu đám đông nhiều nhiễu.

Việc áp dụng chương trình nghiên cứu này vào Việt Nam đòi hỏi phải chuyển đổi cả bối cảnh thể chế lẫn bộ công cụ đo lường. Cơ cấu nền tảng khác biệt căn bản: lãnh đạo doanh nghiệp Việt Nam giao tiếp trên Facebook và ngày càng nhiều trên Zalo; mức độ phổ biến của X là không đáng kể. Các tình huống giao tiếp của lãnh đạo doanh nghiệp rất nổi bật trên thị trường Việt Nam, từ các CEO công nghệ công bố kế hoạch sản phẩm trên trang Facebook cá nhân đến các lãnh đạo công ty môi giới và bất động sản bình luận về tình hình thị trường với lượng người theo dõi lên tới hàng trăm nghìn. Cơ cấu người tiếp nhận làm tăng thêm tầm quan trọng: nhà đầu tư cá nhân chiếm khoảng 80% khối lượng giao dịch (Mục 45.1), và các nhà giao dịch cá nhân chính là những nhà đầu tư có nhiều khả năng nhất theo dõi lãnh đạo doanh nghiệp trên các nền tảng xã hội. Bối cảnh pháp lý cũng đặc thù: Ủy ban Chứng khoán Nhà nước và các cơ quan công an đã nhiều lần xử phạt các cá nhân phát tán thông tin sai lệch có khả năng tác động đến thị trường trên các nền tảng xã hội, và các vụ thao túng thị trường trong những năm gần đây có liên quan đến hoạt động phối hợp trên mạng xã hội, vì vậy một hệ thống giám sát như loại chúng ta xây dựng dưới đây có cả ứng dụng tuân thủ lẫn ứng dụng tìm kiếm alpha.

Việc ánh xạ thiết kế của Crowley, Huang, và Lu (2024b) sang một triển khai tại Việt Nam được tóm tắt trong Bảng 54.2.

Bảng 54.2: Ánh xạ thiết kế nghiên cứu tweet của lãnh đạo sang Việt Nam
Yếu tố thiết kế Crowley, Huang, và Lu (2024b) (Mỹ) Điều chỉnh cho Việt Nam
Mẫu CEO/CFO thuộc S&P 1500, 2011-2018 Lãnh đạo điều hành và chủ tịch của các doanh nghiệp niêm yết trên HOSE/HNX
Nền tảng Twitter/X Trang Facebook, Zalo OA; X nếu có
Nhận diện tài khoản Đối chiếu tên tài khoản + xác minh thủ công Danh bạ được xây dựng từ báo cáo thường niên, công bố thông tin về quản trị công ty, các trang đã xác minh
Bộ phân loại nội dung tài chính ML có giám sát trên các tweet đã gán nhãn Từ điển tài chính tiếng Việt + tinh chỉnh PhoBERT (Mục 53.1) hoặc LLM zero-shot (Mục 53.2)
Thước đo độ tương đồng nội dung Độ tương đồng ML giữa tweet của lãnh đạo và của doanh nghiệp Độ tương đồng cosine trong không gian TF-IDF hoặc vector nhúng dùng chung (Chương 52)
Phản ứng của thị trường Lợi nhuận và khối lượng trong cửa sổ công bố CAR điều chỉnh theo thị trường; suy luận đầy đủ qua khung nghiên cứu sự kiện của chương về nghiên cứu sự kiện
Ma sát chính Reg FD hạn chế việc công bố có chọn lọc Không có quy định tương tự Reg FD; thay vào đó là các thông tư về công bố thông tin và các quy định xử lý thao túng

54.4.1 Kiến trúc dữ liệu cho môi trường vận hành

Hệ thống vận hành bắt đầu bằng một danh bạ các tài khoản của lãnh đạo doanh nghiệp và một bộ thu thập lưu trữ các bài đăng ngay tại thời điểm tiếp nhận. Việc lưu trữ rất quan trọng: các bài đăng thường xuyên bị chỉnh sửa hoặc xóa sau khi thị trường đã phản ứng, vì vậy phiên bản bạn phân tích phải là phiên bản mà nhà đầu tư đã thấy. Mã thu thập dưới đây là bộ khung vận hành; nó đòi hỏi thông tin xác thực API của nền tảng và sự tôn trọng điều khoản dịch vụ của từng nền tảng, và chỉ nên thu thập các bài đăng công khai, liên quan đến hoạt động kinh doanh.

Production: executive account registry and post collector
import sqlite3
import json
from datetime import datetime, timezone

DB_PATH = 'data/exec_social.sqlite'

REGISTRY_SCHEMA = """
CREATE TABLE IF NOT EXISTS exec_accounts (
    ticker        TEXT NOT NULL,     -- listed firm ticker
    exec_name     TEXT NOT NULL,     -- as disclosed in governance reports
    exec_role     TEXT NOT NULL,     -- CEO / CFO / Chair / ...
    platform      TEXT NOT NULL,     -- facebook / zalo / x
    handle        TEXT NOT NULL,     -- page id or username
    verified_by   TEXT,              -- annual report / press / platform badge
    verified_date TEXT,
    PRIMARY KEY (platform, handle)
);
CREATE TABLE IF NOT EXISTS social_posts (
    post_id       TEXT PRIMARY KEY,
    platform      TEXT NOT NULL,
    handle        TEXT NOT NULL,
    posted_at_utc TEXT NOT NULL,
    collected_at  TEXT NOT NULL,
    text          TEXT,
    raw_json      TEXT,              -- full API payload, archived verbatim
    is_deleted    INTEGER DEFAULT 0, -- flipped by re-crawls, never overwritten
    is_edited     INTEGER DEFAULT 0
);
"""

def collect_x_posts(handles: List[str], bearer_token: str) -> List[dict]:
    """Collect recent posts via the X API v2 (tweepy)."""
    import tweepy
    client = tweepy.Client(bearer_token=bearer_token)
    out = []
    for handle in handles:
        user = client.get_user(username=handle)
        tweets = client.get_users_tweets(
            user.data.id, max_results=100,
            tweet_fields=['created_at', 'public_metrics', 'edit_history_tweet_ids']
        )
        for tw in tweets.data or []:
            out.append({
                'post_id': f'x_{tw.id}', 'platform': 'x', 'handle': handle,
                'posted_at_utc': tw.created_at.isoformat(),
                'text': tw.text, 'raw_json': json.dumps(tw.data)
            })
    return out

def collect_facebook_posts(page_ids: List[str], access_token: str) -> List[dict]:
    """Collect public page posts via the Facebook Graph API.

    Personal profiles are not accessible; most Vietnamese executives with a
    public presence operate a public *page*, which is what the registry should
    record. Zalo Official Accounts expose a similar API for OA posts.
    """
    import requests
    out = []
    for pid in page_ids:
        r = requests.get(
            f'https://graph.facebook.com/v19.0/{pid}/posts',
            params={'fields': 'id,created_time,message',
                    'access_token': access_token}
        )
        for post in r.json().get('data', []):
            out.append({
                'post_id': f'fb_{post["id"]}', 'platform': 'facebook',
                'handle': pid, 'posted_at_utc': post['created_time'],
                'text': post.get('message', ''), 'raw_json': json.dumps(post)
            })
    return out

def store_posts(posts: List[dict]):
    """Idempotent insert; re-crawls mark deletions instead of overwriting."""
    con = sqlite3.connect(DB_PATH)
    con.executescript(REGISTRY_SCHEMA)
    now = datetime.now(timezone.utc).isoformat()
    con.executemany(
        """INSERT OR IGNORE INTO social_posts
           (post_id, platform, handle, posted_at_utc, collected_at, text, raw_json)
           VALUES (:post_id, :platform, :handle, :posted_at_utc, :collected_at,
                   :text, :raw_json)""",
        [{**p, 'collected_at': now} for p in posts]
    )
    con.commit(); con.close()

Hai quy ước về thời gian đặc biệt quan trọng đối với dữ liệu Việt Nam. Thứ nhất, dấu thời gian đến ở dạng UTC và phải được chuyển sang giờ Đông Dương (UTC+7). Thứ hai, các bài đăng phải được ánh xạ tới ngày sự kiện theo ngày giao dịch: một bài đăng được đăng sau khi phiên ATC đóng cửa lúc 14:45, hoặc vào cuối tuần hay ngày lễ, thuộc về ngày giao dịch kế tiếp, đúng như cách xử lý dấu thời gian công bố trong chương về nghiên cứu sự kiện.

54.4.2 Môi trường thử nghiệm tái lập: Các bài đăng mô phỏng của lãnh đạo

Phần còn lại của mục này là một bản tái lập hoàn chỉnh, có thể chạy được, của thiết kế của Crowley, Huang, và Lu (2024b) trên dữ liệu Việt Nam mô phỏng, để mọi bước (phân loại, độ tương đồng, phản ứng thị trường) có thể được thực thi và kiểm tra trước khi có bất kỳ thông tin xác thực API nào. Chúng ta mô phỏng 20 mã chứng khoán giả lập trong 500 ngày giao dịch: mỗi doanh nghiệp đăng các thông báo chính thức quanh các ngày công bố kết quả hằng quý, và mỗi lãnh đạo đăng một hỗn hợp gồm nội dung phi tài chính, nội dung tài chính xác nhận diễn giải lại thông báo của chính doanh nghiệp, và nội dung tài chính mới (các phát biểu mang tính định hướng, đưa ra trước bất kỳ bản công bố nào của doanh nghiệp). Phản ánh các phát hiện của bài báo, chúng ta gắn vào dữ liệu một phản ứng giá đối với các bài đăng tài chính của lãnh đạo, lớn nhất đối với nội dung mới. Toàn bộ văn bản là tiếng Việt đã được tách từ sẵn (các từ ghép nối bằng dấu gạch dưới), phù hợp với các quy ước của Mục 48.1. Các mã chứng khoán và lãnh đạo là giả lập theo thiết kế; việc gán các phát biểu mô phỏng cho các doanh nghiệp hay cá nhân có thật là không phù hợp.

Simulate returns, firm posts, and executive posts
rng = np.random.default_rng(42)

TICKERS = [f'T{i:02d}' for i in range(20)]
DATES = pd.bdate_range('2023-01-02', periods=500)

# --- Market and stock returns -------------------------------------------
mkt_ret = rng.normal(0.0004, 0.010, len(DATES))
betas = {t: rng.uniform(0.8, 1.2) for t in TICKERS}
returns = pd.DataFrame({
    t: betas[t] * mkt_ret + rng.normal(0, 0.018, len(DATES))
    for t in TICKERS
}, index=DATES)

# --- Quarterly earnings dates per ticker --------------------------------
earnings_dates = {
    t: [DATES[min(len(DATES) - 1, q * 63 + int(rng.integers(0, 10)))]
        for q in range(1, 8)]
    for t in TICKERS
}

NONFIN_TEMPLATES = [
    'chúc_mừng năm_mới toàn_thể nhân_viên và khách_hàng của công_ty',
    'tự_hào về đội_ngũ nhân_viên tận_tâm của chúng_ta',
    'hôm_nay tôi tham_dự hội_thảo về chuyển_đổi_số và đổi_mới sáng_tạo',
    'cảm_ơn khách_hàng đã luôn đồng_hành cùng chúng_tôi',
    'cuối_tuần đọc một cuốn sách hay về quản_trị doanh_nghiệp',
]
FIRM_FIN_TEMPLATE = ('công_ty công_bố kết_quả kinh_doanh quý '
                     'doanh_thu đạt {rev} tỷ đồng lợi_nhuận tăng {gro} '
                     'phần_trăm so với cùng_kỳ')
CONFIRM_TEMPLATE = ('rất vui chia_sẻ kết_quả kinh_doanh quý '
                    'doanh_thu đạt {rev} tỷ đồng lợi_nhuận tăng {gro} '
                    'phần_trăm so với cùng_kỳ cảm_ơn cả đội_ngũ')
NOVEL_TEMPLATES = [
    'đơn_hàng mới rất khả_quan lợi_nhuận quý tới dự_kiến vượt kế_hoạch',
    'biên lợi_nhuận đang cải_thiện nhờ giá nguyên_liệu giảm mạnh',
    'công_ty chuẩn_bị ký hợp_đồng xuất_khẩu lớn doanh_thu sẽ tăng mạnh',
]

firm_posts, exec_posts = [], []
for t in TICKERS:
    for ed in earnings_dates[t]:
        rev, gro = int(rng.integers(200, 900)), int(rng.integers(5, 40))
        firm_posts.append({'ticker': t, 'date': ed, 'account': 'firm',
                           'text': FIRM_FIN_TEMPLATE.format(rev=rev, gro=gro)})
        # Confirmatory executive post one day after the firm announcement
        if rng.random() < 0.6:
            d = DATES[min(len(DATES) - 1, DATES.get_loc(ed) + 1)]
            exec_posts.append({'ticker': t, 'date': d, 'account': 'executive',
                               'category': 'financial_confirmatory',
                               'text': CONFIRM_TEMPLATE.format(rev=rev, gro=gro)})
        # Novel executive post about a week before the firm announcement
        if rng.random() < 0.4:
            d = DATES[max(0, DATES.get_loc(ed) - int(rng.integers(4, 8)))]
            exec_posts.append({'ticker': t, 'date': d, 'account': 'executive',
                               'category': 'financial_novel',
                               'text': NOVEL_TEMPLATES[int(rng.integers(0, 3))]})
    # Non-financial posts scattered through time
    for _ in range(int(rng.integers(15, 25))):
        d = DATES[int(rng.integers(0, len(DATES)))]
        exec_posts.append({'ticker': t, 'date': d, 'account': 'executive',
                           'category': 'nonfinancial',
                           'text': NONFIN_TEMPLATES[int(rng.integers(0, 5))]})

firm_posts = pd.DataFrame(firm_posts)
exec_posts = pd.DataFrame(exec_posts)

# --- Embed market reactions (the "truth" our tests should recover) ------
for _, p in exec_posts.iterrows():
    i = DATES.get_loc(p.date)
    if p.category == 'financial_novel':
        returns.loc[DATES[i], p.ticker] += 0.010
        if i + 1 < len(DATES):
            returns.loc[DATES[i + 1], p.ticker] += 0.005
    elif p.category == 'financial_confirmatory':
        returns.loc[DATES[i], p.ticker] += 0.003

print(f'Simulated {len(firm_posts)} firm posts and {len(exec_posts)} '
      f'executive posts across {len(TICKERS)} tickers')
print(exec_posts.category.value_counts())
Simulated 140 firm posts and 520 executive posts across 20 tickers
category
nonfinancial              368
financial_confirmatory     85
financial_novel            67
Name: count, dtype: int64

54.4.3 Phân loại nội dung tài chính

Trước tiên, Crowley, Huang, và Lu (2024b) tách các tweet tài chính khỏi tweet phi tài chính bằng một bộ phân loại có giám sát được huấn luyện trên dữ liệu gán nhãn thủ công. Chúng ta tái lập bước đó bằng biểu diễn TF-IDF của Mục 49.2 và một hồi quy logistic. Trong môi trường thử nghiệm, các nhãn đến từ mô phỏng; trong môi trường vận hành, chúng đến từ một vòng gán nhãn thủ công (thường vài trăm bài đăng là đủ để bắt đầu), sau đó bộ phân loại có thể được nâng cấp lên PhoBERT đã tinh chỉnh (Mục 53.1) hoặc bộ phân loại LLM zero-shot (Mục 53.2) mà không cần thay đổi bất cứ điều gì ở các bước phía sau.

Financial-content classifier for Vietnamese posts
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report

exec_posts['is_financial'] = (exec_posts.category != 'nonfinancial').astype(int)

X_train, X_test, y_train, y_test = train_test_split(
    exec_posts.text, exec_posts.is_financial,
    test_size=0.3, random_state=42, stratify=exec_posts.is_financial
)

clf_vectorizer = TfidfVectorizer(token_pattern=r'\S+')
clf = LogisticRegression(max_iter=1000)
clf.fit(clf_vectorizer.fit_transform(X_train), y_train)

y_pred = clf.predict(clf_vectorizer.transform(X_test))
print(classification_report(y_test, y_pred,
                            target_names=['non-financial', 'financial']))

# The most financially loaded tokens, per the fitted model
coefs = pd.Series(clf.coef_[0], index=clf_vectorizer.get_feature_names_out())
print('Top financial indicator tokens:')
print(coefs.sort_values(ascending=False).head(8).round(2))
               precision    recall  f1-score   support

non-financial       1.00      1.00      1.00       110
    financial       1.00      1.00      1.00        46

     accuracy                           1.00       156
    macro avg       1.00      1.00      1.00       156
 weighted avg       1.00      1.00      1.00       156

Top financial indicator tokens:
lợi_nhuận    1.57
mạnh         1.27
tăng         1.27
doanh_thu    1.27
rất          1.21
quý          1.21
chia_sẻ      0.87
cả           0.87
dtype: float64

54.4.4 Đo lường độ tương đồng nội dung giữa lãnh đạo và doanh nghiệp

Thước đo cốt lõi trong Crowley, Huang, và Lu (2024b) là độ tương đồng nội dung giữa một bài đăng của lãnh đạo và các bài đăng gần đây của chính doanh nghiệp, biến hai cơ chế phản ứng của thị trường thành một phép phân tách có thể đo lường: một bài đăng tài chính của lãnh đạo rất giống với nội dung gần đây của doanh nghiệp là xác nhận (phản ứng của nó, nếu có, vận hành qua kênh uy tín), trong khi một bài đăng tài chính không có nội dung tương tự nào từ doanh nghiệp là mới (phản ứng của nó cho thấy thông tin mới đi vào thị trường thông qua lãnh đạo). Chúng ta triển khai thước đo này dưới dạng độ tương đồng cosine tối đa giữa mỗi bài đăng tài chính của lãnh đạo và các bài đăng chính thức của cùng doanh nghiệp trong bảy ngày lịch gần nhất, trong một không gian TF-IDF dùng chung (Mục 52.1).

Executive-firm content similarity and novelty split
from sklearn.metrics.pairwise import cosine_similarity

all_text = pd.concat([exec_posts.text, firm_posts.text])
sim_vectorizer = TfidfVectorizer(token_pattern=r'\S+').fit(all_text)

SIM_WINDOW_DAYS = 7
SIM_THRESHOLD = 0.30

def max_similarity_to_firm(row) -> float:
    window = firm_posts[
        (firm_posts.ticker == row.ticker) &
        (firm_posts.date <= row.date) &
        (firm_posts.date >= row.date - pd.Timedelta(days=SIM_WINDOW_DAYS))
    ]
    if window.empty:
        return 0.0
    sims = cosine_similarity(
        sim_vectorizer.transform([row.text]),
        sim_vectorizer.transform(window.text)
    )
    return float(sims.max())

fin_posts = exec_posts[exec_posts.is_financial == 1].copy()
fin_posts['max_sim_to_firm'] = fin_posts.apply(max_similarity_to_firm, axis=1)
fin_posts['post_type'] = np.where(fin_posts.max_sim_to_firm >= SIM_THRESHOLD,
                                  'confirmatory', 'novel')

print(fin_posts.groupby('post_type').max_sim_to_firm.describe().round(3))
print('\nClassification vs simulated ground truth:')
print(pd.crosstab(fin_posts.post_type, fin_posts.category))

fig, ax = plt.subplots(figsize=(8, 4.5))
sns.histplot(data=fin_posts, x='max_sim_to_firm', hue='category',
             bins=30, ax=ax)
ax.axvline(SIM_THRESHOLD, color='black', linestyle='--',
           label=f'threshold = {SIM_THRESHOLD}')
ax.set_xlabel('Max cosine similarity to firm posts (trailing 7 days)')
ax.set_title('Executive-firm content similarity separates novel from '
             'confirmatory posts')
plt.tight_layout()
plt.show()
              count   mean    std    min    25%    50%    75%    max
post_type                                                           
confirmatory   85.0  0.812  0.007  0.791  0.809  0.813  0.819  0.826
novel          67.0  0.000  0.000  0.000  0.000  0.000  0.000  0.000

Classification vs simulated ground truth:
category      financial_confirmatory  financial_novel
post_type                                            
confirmatory                      85                0
novel                              0               67

Phân phối độ tương đồng có dạng hai đỉnh rõ rệt: các bài đăng diễn giải lại kết quả kinh doanh nằm gần đầu trên của miền giá trị trong khi các bài đăng mang tính định hướng tập trung gần 0, do đó phép phân tách không nhạy với ngưỡng chính xác. Trong môi trường vận hành, ngưỡng nên được kiểm định trên một mẫu gán nhãn thủ công, và không gian TF-IDF có thể được thay bằng vector nhúng câu PhoBERT (Mục 52.2) để bắt được các bài diễn giải lại có cùng ý nghĩa nhưng không cùng từ vựng.

54.4.5 Phản ứng của thị trường đối với các bài đăng của lãnh đạo

Khi các bài đăng đã được phân loại và tách nhóm, kiểm định phản ứng thị trường theo logic của nghiên cứu sự kiện: tính lợi nhuận bất thường điều chỉnh theo thị trường và cộng dồn chúng trong cửa sổ bài đăng, riêng cho các bài đăng tài chính mới, tài chính xác nhận và phi tài chính. Các bài đăng phi tài chính đóng vai trò nhóm giả dược, đúng như trong Crowley, Huang, và Lu (2024b); nếu những lời chào hỏi và chúc mừng ngày lễ làm dịch chuyển giá, thiết kế đã bị nhiễm.

CARs around executive posts by post type
from scipy import stats

abnormal = returns.sub(pd.Series(mkt_ret, index=DATES), axis=0)

def car_around(ticker: str, date: pd.Timestamp, w: Tuple[int, int]) -> float:
    i = DATES.get_loc(date)
    lo, hi = max(0, i + w[0]), min(len(DATES) - 1, i + w[1])
    return float(abnormal[ticker].iloc[lo:hi + 1].sum())

events = pd.concat([
    fin_posts[['ticker', 'date', 'post_type']],
    exec_posts.loc[exec_posts.is_financial == 0,
                   ['ticker', 'date']].assign(post_type='nonfinancial')
])
events['car_0_1'] = events.apply(
    lambda r: car_around(r.ticker, r.date, (0, 1)), axis=1)

summary = []
for ptype, grp in events.groupby('post_type'):
    tstat, pval = stats.ttest_1samp(grp.car_0_1, 0.0)
    summary.append({'post_type': ptype, 'n': len(grp),
                    'mean_car_pct': 100 * grp.car_0_1.mean(),
                    't_stat': tstat, 'p_value': pval})
summary = (pd.DataFrame(summary)
           .set_index('post_type')
           .loc[['novel', 'confirmatory', 'nonfinancial']])
print(summary.round(3))

novel = events.loc[events.post_type == 'novel', 'car_0_1']
confirm = events.loc[events.post_type == 'confirmatory', 'car_0_1']
t_diff, p_diff = stats.ttest_ind(novel, confirm, equal_var=False)
print(f'\nNovel minus confirmatory: '
      f'{100 * (novel.mean() - confirm.mean()):.2f} pp '
      f'(t = {t_diff:.2f}, p = {p_diff:.4f})')

fig, ax = plt.subplots(figsize=(8, 4.5))
order = ['novel', 'confirmatory', 'nonfinancial']
means = summary.loc[order, 'mean_car_pct']
errs = [100 * 1.96 * events.loc[events.post_type == p, 'car_0_1'].sem()
        for p in order]
ax.bar(order, means, yerr=errs, capsize=5)
ax.axhline(0, color='black', linewidth=0.8)
ax.set_ylabel('Mean CAR[0,+1], %')
ax.set_title('Market reactions to executive posts by content type')
plt.tight_layout()
plt.show()
                n  mean_car_pct  t_stat  p_value
post_type                                       
novel          67         1.275   3.935    0.000
confirmatory   85         0.264   0.954    0.343
nonfinancial  368         0.108   0.831    0.407

Novel minus confirmatory: 1.01 pp (t = 2.37, p = 0.0189)

Môi trường thử nghiệm khôi phục đúng cấu trúc mà chúng ta đã gắn vào, cũng là cấu trúc mà Crowley, Huang, và Lu (2024b) tìm thấy trong dữ liệu thực của Mỹ: các bài đăng tài chính mới mang phản ứng lớn và có ý nghĩa thống kê (kênh thông tin mới), trong khi các bài đăng phi tài chính là nhóm giả dược không có phản ứng. Nhóm xác nhận mang tính gợi mở theo một cách khác: phản ứng được gắn vào là có thật nhưng nhỏ, và với cỡ mẫu mô phỏng này thì không thể phân biệt một cách đáng tin cậy với 0, một lời nhắc rằng các hiệu ứng của kênh uy tín đòi hỏi bảng dữ liệu bài đăng lớn mới phát hiện được (Crowley, Huang, và Lu (2024b) nhận diện kênh của họ từ toàn bộ lịch sử tweet của S&P 1500), vì vậy một nhà nghiên cứu Việt Nam nên kỳ vọng kết quả của các bài đăng mới xuất hiện trước và kết quả của nhóm xác nhận cần tích lũy nhiều năm dữ liệu. Trên dữ liệu Việt Nam thực, CAR điều chỉnh theo thị trường đơn giản này nên được thay bằng bộ máy suy luận đầy đủ của chương về nghiên cứu sự kiện (lợi nhuận bất thường theo mô hình rủi ro, các kiểm định BMP và kiểm định hạng, và các cửa sổ rộng hơn để tính đến biên độ giá), và các phản ứng nên được đo bằng khối lượng và biến động cũng như lợi nhuận, vì các hiệu ứng thu hút sự chú ý do nhà đầu tư cá nhân thúc đẩy thường xuất hiện ở khối lượng trước.

54.4.6 Từ tái lập đến giám sát vận hành

Mọi thứ ở trên hợp lại thành một hệ thống giám sát chạy liên tục: thu thập các bài đăng mới, phân loại nội dung tài chính, tính độ tương đồng với dòng bài đăng chính thức của doanh nghiệp, và phản ứng trong vòng vài phút thay vì vào cuối một quý nghiên cứu. Bộ khung dưới đây được cố ý giữ ở mức tối giản; mỗi phương thức ủy quyền cho một thành phần đã được xây dựng trong mục này.

Production: real-time executive post monitor
class ExecutiveSocialMonitor:
    """Poll -> classify -> similarity -> alert, on a fixed schedule.

    Schedule with cron / Task Scheduler at the desired latency, e.g.
    every 5 minutes during HOSE trading hours (09:00-14:45 ICT).
    """

    def __init__(self, db_path, clf, clf_vectorizer, sim_vectorizer,
                 sim_threshold=0.30, sim_window_days=7):
        self.db_path = db_path
        self.clf, self.clf_vectorizer = clf, clf_vectorizer
        self.sim_vectorizer = sim_vectorizer
        self.sim_threshold = sim_threshold
        self.sim_window_days = sim_window_days

    def poll(self):
        """Collect new posts for every registered account (see
        exec-social-collection) and return those not yet processed."""
        ...

    def classify(self, posts):
        """Word-segment (underthesea), then flag financial content."""
        ...

    def similarity(self, post):
        """Max cosine similarity vs the firm's official posts in the
        trailing window; below threshold => novel."""
        ...

    def alert(self, post, is_novel):
        """Route by rule:
        - novel financial post by a covered executive -> analyst channel
          (candidate trading signal; log expected reaction window)
        - quantitative claim with no firm disclosure within 24h ->
          compliance flag (selective disclosure / manipulation risk)
        - deleted or edited financial post -> archive diff + escalate
        """
        ...

    def run_once(self):
        new_posts = self.poll()
        for post in self.classify(new_posts):
            if post['is_financial']:
                self.alert(post, self.similarity(post) < self.sim_threshold)
Tuân thủ và đạo đức trong bối cảnh Việt Nam

A production monitor must respect three constraints. First, collect only public business communications of public-company executives; Vietnam’s Personal Data Protection Decree (13/2023/NĐ-CP) and platform terms of service both bind, and private profiles are out of scope. Second, the same system that surfaces trading signals surfaces manipulation: executives or affiliated accounts posting inflated claims ahead of insider sales is exactly the pattern securities regulators sanction, so log everything immutably; the archive is evidence. Third, trading on a novel executive post is trading on public information the moment it is posted, but a researcher who receives material information privately from an executive is in different legal territory entirely. Jung và c.s. (2018) is the reminder that the observable stream is strategically selected; absence of posts is not absence of news.

Danh mục kiểm tra khi triển khai
  1. Danh bạ: các tài khoản lãnh đạo được xác minh từ báo cáo thường niên và công bố thông tin về quản trị công ty; xác minh lại hằng quý.
  2. Thu thập: truy vấn định kỳ theo lịch với kho lưu trữ JSON thô bất biến; theo dõi việc xóa và chỉnh sửa; chuyển đổi UTC sang ICT; ánh xạ mốc ATC sang các ngày giao dịch.
  3. Mô hình: bộ phân loại được huấn luyện lại trên tập gán nhãn ngày càng lớn; ngưỡng độ tương đồng được kiểm định lại; vector nhúng PhoBERT khi khối lượng bài diễn giải lại đủ lớn.
  4. Đo lường: CAR từ quy trình của chương về nghiên cứu sự kiện; phản ứng về khối lượng và biến động đi kèm lợi nhuận.
  5. Quản trị: cảnh báo tuân thủ được định tuyến tách biệt với cảnh báo nghiên cứu; rà soát hằng quý tỷ lệ dương tính giả của cả hai.

Chương trình nghiên cứu mà điều này mở ra tại Việt Nam phần lớn vẫn còn bỏ ngỏ: chưa có nghiên cứu nào được công bố đo lường liệu các bài đăng Facebook và Zalo của lãnh đạo doanh nghiệp Việt Nam có làm dịch chuyển giá cổ phiếu của doanh nghiệp họ hay không, phản ứng khác nhau thế nào giữa cơ sở nhà đầu tư cá nhân đông đảo trên HOSE và các nhà đầu tư tổ chức, hay liệu cơ chế uy tín của Crowley, Huang, và Lu (2024b) có mạnh hơn trong một thị trường mà niềm tin giữa người với người có thể thay thế cho một môi trường công bố thông tin chính thức mỏng hơn. Quy trình trong mục này là hạ tầng cho cả ba câu hỏi.

55 So sánh phương pháp và thực hành tốt nhất

Bảng 55.1: So sánh các phương pháp phân tích văn bản cho văn bản tài chính tiếng Việt
Phương pháp Khả năng diễn giải Ngữ nghĩa Tốc độ Hỗ trợ tiếng Việt Yêu cầu dữ liệu Trường hợp sử dụng tốt nhất
BoW/TF-IDF Cao Thấp Nhanh Tốt* Không Nhóm doanh nghiệp cùng ngành, tương đồng từ vựng
LDA Trung bình Thấp Trung bình Tốt* Không Khám phá chủ đề
Doc2Vec Thấp Trung bình Trung bình Tốt* Kho ngữ liệu Tương đồng tài liệu
BERTopic Cao Cao Chậm Xuất sắc Không Các chủ đề mạch lạc
PhoBERT Thấp Cao Chậm Xuất sắc Tinh chỉnh Cảm xúc, NER, phân loại
Sentence-BERT Thấp Cao Trung bình Tốt Không Tương đồng ngữ nghĩa
LLM (zero-shot) Cao Cao Chậm Tốt Không Trích xuất, phân loại
Ghi chú

*Yêu cầu tách từ tiếng Việt như một bước tiền xử lý. Cột Hỗ trợ tiếng Việt đánh giá mức độ phương pháp xử lý văn bản tiếng Việt một cách tự nhiên.

Đối với các nhà nghiên cứu mới bắt đầu phân tích văn bản của các doanh nghiệp Việt Nam, chúng tôi khuyến nghị quy trình làm việc sau:

  1. Bắt đầu với độ tương đồng cosine TF-IDF để xác định nhóm doanh nghiệp cùng ngành vì nó nhanh, dễ diễn giải và cung cấp một mức cơ sở mạnh.
  2. Dùng BERTopic với vector nhúng PhoBERT để khám phá chủ đề vì nó tạo ra các chủ đề mạch lạc hơn LDA đối với văn bản tiếng Việt.
  3. Đối với phân tích cảm xúc, dùng ViFinBERT nếu có dữ liệu để tinh chỉnh; nếu không, phân loại LLM zero-shot cho kết quả cạnh tranh.
  4. Đối với các hệ thống vận hành cần phân tích theo thời gian thực, vector nhúng sentence-BERT mang lại sự đánh đổi tốt nhất giữa tốc độ và độ chính xác.
Compare methods on peer identification accuracy
# Evaluate: what fraction of top-5 peers share ICB sector?
methods = {
    'TF-IDF': sim_df,
    'Sentence-BERT': embed_sim_df,
    'Doc2Vec': pd.DataFrame(
        cosine_similarity(d2v_vectors),
        index=tickers, columns=tickers
    ),
}

accuracy_results = {}
sector_map = corpus_df.set_index('ticker')['icb_sector'].to_dict()

for method_name, sim_matrix_df in methods.items():
    matches = 0
    total = 0
    for ticker in tickers:
        true_sector = sector_map.get(ticker)
        peers = sim_matrix_df[ticker].drop(ticker).nlargest(5)
        for peer in peers.index:
            total += 1
            if sector_map.get(peer) == true_sector:
                matches += 1
    accuracy_results[method_name] = matches / total

fig, ax = plt.subplots(figsize=(8, 5))
methods_list = list(accuracy_results.keys())
accs = list(accuracy_results.values())
bars = ax.bar(methods_list, accs, color=['#2C5282', '#38A169', '#D69E2E'])
ax.set_ylabel('ICB Sector Match Rate')
ax.set_title('Peer Identification Accuracy by Method')
ax.set_ylim(0, 1)
for bar, acc in zip(bars, accs):
    ax.text(bar.get_x() + bar.get_width()/2., bar.get_height() + 0.02,
            f'{acc:.1%}', ha='center', fontweight='bold')
plt.tight_layout()
plt.show()
Hình 55.1

56 Kết luận

Chương này đã trình bày toàn bộ quy trình các phương pháp phân tích văn bản áp dụng cho các doanh nghiệp niêm yết Việt Nam, từ các cách tiếp cận bag-of-words cổ điển đến các mô hình ngôn ngữ dựa trên transformer. Những điểm then chốt dành cho người thực hành và nhà nghiên cứu là:

Thứ nhất, tiền xử lý văn bản tiếng Việt đòi hỏi một bước tách từ mà NLP tiếng Anh không có tương đương. Sử dụng các công cụ như VnCoreNLP hoặc underthesea cho bước này là thiết yếu và ảnh hưởng đáng kể đến chất lượng của các phân tích phía sau.

Thứ hai, các từ điển cảm xúc theo lĩnh vực chuyên biệt vượt trội đáng kể so với các từ điển đa dụng đối với văn bản tài chính tiếng Việt, phù hợp với các phát hiện của Loughran và McDonald (2011) cho tiếng Anh.

Thứ ba, vector nhúng dựa trên PhoBERT nắm bắt sự tương đồng ngữ nghĩa mà TF-IDF bỏ sót, nhận diện các doanh nghiệp cùng ngành có chung mô hình kinh doanh ngay cả khi họ dùng từ vựng khác nhau.

Thứ tư, các LLM cho phép những ứng dụng mới, bao gồm trích xuất thông tin có cấu trúc từ báo cáo thường niên tiếng Việt vốn sẽ cực kỳ tốn kém nếu mã hóa thủ công.

Các ứng dụng thực nghiệm cho thấy các thước đo văn bản chứa thông tin có ý nghĩa kinh tế đối với thị trường Việt Nam. Cảm xúc ròng từ báo cáo thường niên dự báo lợi nhuận cổ phiếu tiếp theo ngay cả sau khi kiểm soát các nhân tố rủi ro tiêu chuẩn, và các thước đo cảm xúc dựa trên BERT có sức dự báo gia tăng vượt trên các thước đo dựa trên từ điển. Các phân loại ngành dựa trên văn bản nắm bắt các mối quan hệ giữa doanh nghiệp mà các mã ICB tĩnh bỏ sót, và những thay đổi về độ tương đồng văn bản quanh các sự kiện doanh nghiệp phản ánh những chuyển đổi kinh doanh thực sự.


  1. Vietnamese text requires specialized tokenization due to compound words (e.g., “công ty” = company, “thị trường” = market).↩︎

  2. Loughran và McDonald (2011) cho thấy các từ điển đa dụng phân loại sai tới 73% số từ tiêu cực trong văn bản tài chính.↩︎

  3. Reimers và Gurevych (2019) chứng minh rằng vector nhúng sentence-BERT giảm chi phí tính toán cho các tác vụ đo độ tương đồng từ 65 giờ xuống còn 5 giây trên 10.000 cặp câu.↩︎