46  Dữ liệu hình ảnh và dữ liệu thị giác trong tài chính

Chương trước đã trình bày cách văn bản phi cấu trúc (ví dụ: báo cáo kết quả kinh doanh, bài báo tin tức, phần mô tả hoạt động kinh doanh) có thể được chuyển thành các tín hiệu có cấu trúc cho phân tích tài chính. Chương này mở rộng bộ công cụ dữ liệu thay thế sang một dạng thức thứ hai: hình ảnh. Dữ liệu thị giác rất dồi dào trong bối cảnh tài chính nhưng bị khai thác chưa đầy đủ một cách có hệ thống. Ảnh vệ tinh phản ánh hoạt động kinh tế thực (ví dụ: mức độ lấp đầy bãi đỗ xe tại các điểm bán lẻ, tiến độ xây dựng tại các khu công nghiệp, độ sáng ban đêm như một biến đại diện cho GDP khu vực, lưu lượng tàu tại các cảng, sức khỏe cây trồng trên các vùng nông nghiệp). Các tài liệu của doanh nghiệp đến dưới dạng PDF quét, với các bảng biểu và hình vẽ khó trích xuất bằng các phương pháp trích xuất văn bản tiêu chuẩn. Các biểu đồ tài chính mã hóa thông tin mà các nhà phân tích diễn giải bằng mắt nhưng các chiến lược có hệ thống không thể sử dụng nếu chưa được số hóa. Và nội dung thị giác của mạng xã hội, quảng cáo và hình ảnh sản phẩm mang các tín hiệu về cảm xúc và thương hiệu bổ sung cho phân tích văn bản.

Thách thức cốt lõi nằm ở khâu biểu diễn: một hình ảnh là một tensor ba chiều gồm các cường độ điểm ảnh, không có cấu trúc ngữ nghĩa vốn có. Việc chuyển mảng thô này thành một tín hiệu tài chính (tức là một con số dự báo lợi nhuận, đo lường rủi ro hoặc đại diện cho hoạt động kinh tế) đòi hỏi hoặc kỹ thuật thiết kế đặc trưng thủ công, hoặc các biểu diễn được học thông qua mạng nơ-ron tích chập sâu (CNN) và vision transformer (ViT). Chương này trình bày cả hai cách tiếp cận.

Chúng ta tổ chức nội dung xoay quanh năm lĩnh vực ứng dụng, mỗi lĩnh vực có nguồn dữ liệu, yêu cầu mô hình hóa và động cơ kinh tế riêng. Thứ nhất, ảnh vệ tinh và ảnh địa không gian để dự báo tức thời (nowcasting) hoạt động kinh tế. Thứ hai, phân tích ảnh tài liệu để trích xuất dữ liệu có cấu trúc từ các hồ sơ tài chính tiếng Việt. Thứ ba, số hóa biểu đồ và hình vẽ cho kiểm định ngược có hệ thống. Thứ tư, phân tích cảm xúc thị giác từ mạng xã hội và truyền thông tin tức. Thứ năm, hợp nhất đa phương thức, kết hợp tín hiệu hình ảnh và văn bản thành các mô hình dự báo chung.

Thị trường Việt Nam có những cơ hội đặc thù trong lĩnh vực này. Ảnh vệ tinh đặc biệt hữu ích trong một nền kinh tế có các ngành nông nghiệp và sản xuất lớn, nơi dữ liệu thực địa đến với độ trễ đáng kể. Các hồ sơ tài chính của Việt Nam thường được phát hành dưới dạng ảnh quét thay vì định dạng máy đọc được, khiến AI xử lý tài liệu trở thành điều thiết yếu chứ không phải tùy chọn. Và quá trình đô thị hóa nhanh chóng thể hiện trong ảnh xây dựng và hạ tầng cung cấp các biến đại diện tần suất cao cho động lực kinh tế vĩ mô mà thống kê chính thức không thể sánh được.

Hiện mã
import pandas as pd
import numpy as np
from pathlib import Path
import warnings
warnings.filterwarnings("ignore")

# Core image processing
from PIL import Image
import io

# Deep learning
import torch
import torch.nn as nn
import torchvision.transforms as transforms
import torchvision.models as models

# Visualization
import plotnine as p9
from mizani.formatters import percent_format, comma_format
import matplotlib.pyplot as plt

# Statistical analysis
from scipy import stats
import statsmodels.api as sm
import statsmodels.formula.api as smf
from linearmodels.panel import PanelOLS

46.1 Nền tảng: Từ điểm ảnh đến tín hiệu tài chính

46.1.1 Biểu diễn hình ảnh

Một hình ảnh số là một hàm \(I: \{1, \ldots, H\} \times \{1, \ldots, W\} \times \{1, \ldots, C\} \rightarrow [0, 255]\) ánh xạ tọa độ không gian và các kênh màu thành các giá trị cường độ. Với một ảnh RGB có chiều cao \(H\) và chiều rộng \(W\), biểu diễn là một tensor \(\mathbf{I} \in \mathbb{R}^{H \times W \times 3}\). Một ảnh RGB \(224 \times 224\) đơn lẻ (tức là đầu vào tiêu chuẩn của các CNN hiện đại) chứa \(224 \times 224 \times 3 = 150{,}528\) chiều. Số chiều cực lớn này, kết hợp với cấu trúc không gian (các điểm ảnh lân cận tương quan với nhau), khiến hình ảnh khác biệt căn bản so với dữ liệu tài chính dạng bảng và đòi hỏi các kiến trúc chuyên biệt.

Điểm mấu chốt của mạng nơ-ron tích chập là chia sẻ tham số thông qua các bộ lọc cục bộ. Một lớp tích chập áp dụng một nhân \(\mathbf{K} \in \mathbb{R}^{k \times k \times C_{\text{in}}}\) để tạo ra một bản đồ đặc trưng:

\[ (\mathbf{I} * \mathbf{K})(i, j) = \sum_{m=0}^{k-1} \sum_{n=0}^{k-1} \sum_{c=1}^{C_{\text{in}}} I(i+m, j+n, c) \cdot K(m, n, c) \tag{46.1}\]

Bằng cách xếp chồng các lớp tích chập với các hàm phi tuyến và các phép gộp (pooling), mạng xây dựng một hệ thống phân cấp các biểu diễn: các lớp đầu phát hiện cạnh và kết cấu; các lớp giữa phát hiện các bộ phận và mẫu hình; các lớp sâu phát hiện đối tượng và cảnh vật. Đầu ra của lớp cuối \(\mathbf{z} \in \mathbb{R}^{d}\) (với \(d\) thường là 512-2048) là một biểu diễn gọn của nội dung ngữ nghĩa của hình ảnh, có thể được dùng trực tiếp làm vector đặc trưng cho dự báo tài chính.

Hình ảnh tài chính trải rộng trên nhiều độ phân giải và dạng thức:

Bảng 46.1: Các nguồn dữ liệu hình ảnh cho thị trường tài chính Việt Nam
Nguồn Độ phân giải Kênh Kích thước điển hình Tần suất cập nhật
Vệ tinh Sentinel-2 10m/pixel 13 băng tần 10,980 × 10,980 5 ngày
Planet Labs 3m/pixel 4 băng tần 4,000 × 4,000 Hằng ngày
Độ sáng ban đêm VIIRS 500m/pixel 1 (DNB) 3,000 × 1,800 Tổng hợp hằng tháng
Bản quét báo cáo thường niên 300 DPI 3 (RGB) 2,480 × 3,508 Hằng năm
Ảnh chân dung CEO Thay đổi 3 (RGB) 500 × 500 Hằng năm
Ảnh tin tức Thay đổi 3 (RGB) 800 × 600 Thời gian thực
Biểu đồ tài chính Thay đổi 3 (RGB) 1,000 × 600 Thời gian thực

46.1.2 Học chuyển giao cho tài chính

Huấn luyện một CNN từ đầu đòi hỏi hàng triệu ảnh có gán nhãn, nhiều hơn rất nhiều so với bất kỳ ứng dụng tài chính nào có thể cung cấp. Học chuyển giao (transfer learning) giải quyết vấn đề này bằng cách dùng các mạng đã được tiền huấn luyện trên ImageNet (1,2 triệu ảnh, 1.000 lớp) làm bộ trích xuất đặc trưng. Mạng tiền huấn luyện đã học được các biểu diễn thị giác chung (cạnh, kết cấu, hình dạng, đối tượng); chúng ta chỉ cần thay lớp phân loại cuối cùng bằng một lớp đầu ra (head) riêng cho tác vụ.

Về mặt hình thức, gọi \(f_{\boldsymbol{\theta}}(\mathbf{I})\) là một mạng tiền huấn luyện với các tham số \(\boldsymbol{\theta}\) được chia thành bộ trích xuất đặc trưng \(\boldsymbol{\theta}_{\text{feat}}\) và bộ phân loại \(\boldsymbol{\theta}_{\text{cls}}\). Đối với các ứng dụng tài chính, chúng ta:

  1. Trích xuất đặc trưng: Đóng băng \(\boldsymbol{\theta}_{\text{feat}}\), trích xuất \(\mathbf{z} = f_{\boldsymbol{\theta}_{\text{feat}}}(\mathbf{I})\), và huấn luyện một mô hình đơn giản (hồi quy tuyến tính, gradient boosting) trên \(\mathbf{z}\).
  2. Tinh chỉnh: Khởi tạo từ \(\boldsymbol{\theta}\) và huấn luyện tất cả các tham số trên tác vụ tài chính với tốc độ học nhỏ để tránh quên thảm khốc (catastrophic forgetting).

Các họ kiến trúc chính là:

ResNet [He và c.s. (2016)]. Các kết nối phần dư (\(y = F(x) + x\)) cho phép huấn luyện các mạng rất sâu (50-152 lớp). Kết nối tắt giải quyết vấn đề triệt tiêu gradient. ResNet-50 tạo ra một vector đặc trưng 2.048 chiều từ lớp áp chót.

EfficientNet [Tan và Le (2019)]. Mở rộng kết hợp (compound scaling) đồng thời độ sâu, độ rộng và độ phân giải. EfficientNet-B0 đạt độ chính xác của ResNet-50 với 5,3 triệu tham số (so với 25,6 triệu), khiến nó khả thi để xử lý hàng nghìn ô ảnh vệ tinh.

Vision Transformer (ViT) [Dosovitskiy và c.s. (2020)]. Xem một hình ảnh như một chuỗi các mảnh ảnh (patch) \(16 \times 16\), xử lý chúng qua một bộ mã hóa Transformer tiêu chuẩn. ViT-B/16 tạo ra một vector nhúng 768 chiều. Đặc biệt hiệu quả đối với ảnh tài liệu, nơi các quan hệ không gian giữa các thành phần (bảng, tiêu đề, khối văn bản) đóng vai trò quan trọng.

Hiện mã
# DataCore.vn API
from datacore import DataCore
dc = DataCore()

def build_feature_extractor(model_name="resnet50", device="cpu"):
    """
    Build a pre-trained CNN feature extractor.

    Parameters
    ----------
    model_name : str
        One of 'resnet50', 'efficientnet_b0', 'vit_b_16'.
    device : str
        'cpu' or 'cuda'.

    Returns
    -------
    model : nn.Module
        Feature extraction model.
    transform : transforms.Compose
        Image preprocessing pipeline.
    """
    if model_name == "resnet50":
        weights = models.ResNet50_Weights.IMAGENET1K_V2
        model = models.resnet50(weights=weights)
        model.fc = nn.Identity()  # Remove classification head
        dim = 2048
    elif model_name == "efficientnet_b0":
        weights = models.EfficientNet_B0_Weights.IMAGENET1K_V1
        model = models.efficientnet_b0(weights=weights)
        model.classifier = nn.Identity()
        dim = 1280
    elif model_name == "vit_b_16":
        weights = models.ViT_B_16_Weights.IMAGENET1K_V1
        model = models.vit_b_16(weights=weights)
        model.heads = nn.Identity()
        dim = 768

    model = model.to(device).eval()

    transform = transforms.Compose([
        transforms.Resize(256),
        transforms.CenterCrop(224),
        transforms.ToTensor(),
        transforms.Normalize(
            mean=[0.485, 0.456, 0.406],
            std=[0.229, 0.224, 0.225]
        )
    ])

    return model, transform, dim


def extract_features(image_paths, model, transform, device="cpu",
                     batch_size=32):
    """
    Extract deep features from a list of images.

    Parameters
    ----------
    image_paths : list
        Paths to image files.
    model : nn.Module
        Feature extraction model.
    transform : transforms.Compose
        Preprocessing pipeline.

    Returns
    -------
    np.ndarray : Feature matrix (n_images x feature_dim).
    """
    features = []

    for i in range(0, len(image_paths), batch_size):
        batch_paths = image_paths[i:i + batch_size]
        batch_tensors = []

        for path in batch_paths:
            try:
                img = Image.open(path).convert("RGB")
                tensor = transform(img)
                batch_tensors.append(tensor)
            except Exception:
                batch_tensors.append(torch.zeros(3, 224, 224))

        batch = torch.stack(batch_tensors).to(device)

        with torch.no_grad():
            batch_features = model(batch).cpu().numpy()

        features.append(batch_features)

    return np.vstack(features)

46.2 Ảnh vệ tinh và ảnh địa không gian

46.2.1 Hoạt động kinh tế nhìn từ không gian

Ảnh vệ tinh cung cấp các phép đo hoạt động kinh tế có tần suất cao và chi tiết về không gian, độc lập với và thường đi trước thống kê chính thức. Công trình nền tảng của Henderson, Storeygard, và Weil (2012) đã chứng minh rằng độ sáng ban đêm, được đo bởi Chương trình Vệ tinh Khí tượng Quốc phòng (DMSP), là một biến đại diện đáng tin cậy cho GDP, đặc biệt ở những quốc gia có thống kê chính thức nhiều nhiễu hoặc chậm trễ. Donaldson và Storeygard (2016) khảo cứu các ứng dụng rộng rãi của dữ liệu vệ tinh trong kinh tế học, từ năng suất nông nghiệp đến tăng trưởng đô thị và đo lường xung đột. Donaldson (2018) dùng các thước đo sản lượng nông nghiệp lấy từ vệ tinh để nghiên cứu lợi ích phúc lợi của đường sắt ở Ấn Độ thuộc địa. Jean và c.s. (2016) kết hợp ảnh vệ tinh ban ngày với các CNN để dự đoán nghèo đói từ không gian với \(r^2 > 0.7\).

Tài liệu kinh tế chính trị khai thác dữ liệu vệ tinh để trả lời những câu hỏi không thể giải đáp bằng thống kê chính thức. Hodler và Raschky (2014) dùng độ sáng ban đêm để cho thấy các vùng dưới cấp quốc gia có liên kết chính trị với nhà lãnh đạo quốc gia có mức hoạt động kinh tế cao hơn hẳn so với các vùng khác (ví dụ: một phát hiện về sự thiên vị theo vùng có liên quan trực tiếp đến việc phân bổ đầu tư cấp tỉnh của Việt Nam). Henderson và c.s. (2018) mở rộng khung độ sáng để nghiên cứu phân bố toàn cầu của hoạt động kinh tế, cho thấy địa lý, lịch sử và thương mại cùng quyết định hình thái phân bố không gian của sự thịnh vượng.

Đối với các ứng dụng tài chính, điểm mấu chốt là dữ liệu vệ tinh đến nhanh hơn lợi nhuận kế toán của doanh nghiệp hay thống kê của chính phủ. Doanh thu hằng quý của một nhà bán lẻ được báo cáo 4-8 tuần sau khi quý kết thúc; ảnh vệ tinh về các bãi đỗ xe của họ có sẵn trong vòng vài ngày. Lợi thế về thời gian này tạo ra một trường hợp sử dụng tự nhiên cho dự báo tức thời (tức là ước tính các điều kiện kinh tế hiện tại trước khi dữ liệu chính thức được công bố) và cho việc xây dựng các tín hiệu giao dịch dựa trên thông tin công khai nhưng tốn kém để xử lý.

46.2.2 Ứng dụng 1: Độ sáng ban đêm và GDP cấp tỉnh

Tổng cục Thống kê (GSO) của Việt Nam công bố GDP cấp tỉnh với độ trễ vài tháng. Độ sáng ban đêm từ cảm biến VIIRS (Visible Infrared Imaging Radiometer Suite) cung cấp một giải pháp thay thế gần như theo thời gian thực. Chúng ta xây dựng một thước đo mức độ phơi nhiễm ở cấp doanh nghiệp bằng cách liên kết địa điểm đăng ký của mỗi doanh nghiệp niêm yết với độ sáng của tỉnh đó.

Hiện mã
# Load nighttime luminosity data (VIIRS monthly composites)
# Source: Earth Observation Group (EOG) / NOAA
nightlights = dc.get_nightlight_data(
    start_date="2014-01-01",
    end_date="2024-12-31",
    resolution="province"
)

# Load firm location data
firm_locations = dc.get_firm_locations()

# Provincial GDP from GSO
provincial_gdp = dc.get_provincial_gdp(
    start_date="2014-01-01",
    end_date="2024-12-31"
)

print(f"Nightlight observations: {len(nightlights)}")
print(f"Provinces covered: {nightlights['province'].nunique()}")
print(f"Firms with location: {firm_locations['ticker'].nunique()}")
Hiện mã
# Validate: does nightlight predict provincial GDP?
nl_gdp = nightlights.merge(
    provincial_gdp,
    on=["province", "year", "quarter"],
    how="inner"
)

# Log-log specification (standard in the literature)
nl_gdp["ln_luminosity"] = np.log(nl_gdp["mean_radiance"].clip(lower=0.01))
nl_gdp["ln_gdp"] = np.log(nl_gdp["provincial_gdp"].clip(lower=1))

# Cross-sectional regression by year
validation_results = []
for year in nl_gdp["year"].unique():
    subset = nl_gdp[nl_gdp["year"] == year]
    if len(subset) < 20:
        continue

    model = sm.OLS(
        subset["ln_gdp"],
        sm.add_constant(subset["ln_luminosity"])
    ).fit()

    validation_results.append({
        "year": year,
        "beta": model.params.iloc[1],
        "r_squared": model.rsquared,
        "n_provinces": int(model.nobs)
    })

validation_df = pd.DataFrame(validation_results)
print(f"Avg R² (ln GDP ~ ln Luminosity): {validation_df['r_squared'].mean():.3f}")
Hiện mã
(
    p9.ggplot(nl_gdp[nl_gdp["year"] == 2023],
              p9.aes(x="ln_luminosity", y="ln_gdp"))
    + p9.geom_point(color="#2E5090", alpha=0.6, size=2)
    + p9.geom_smooth(method="lm", color="#C0392B", se=True, size=0.8)
    + p9.labs(
        x="ln(Mean Nighttime Radiance)",
        y="ln(Provincial GDP)",
        title="Nighttime Luminosity vs. Provincial GDP (2023)"
    )
    + p9.theme_minimal()
    + p9.theme(figure_size=(10, 6))
)
Hình 46.1
Hiện mã
# Construct firm-level nightlight signal
# Logic: firms in provinces with accelerating luminosity
# are experiencing positive local economic conditions

nl_growth = nightlights.copy().sort_values(["province", "year", "quarter"])

# Year-over-year luminosity growth by province
nl_growth["ln_radiance"] = np.log(nl_growth["mean_radiance"].clip(lower=0.01))
nl_growth["ln_radiance_lag4"] = nl_growth.groupby(
    "province"
)["ln_radiance"].shift(4)

nl_growth["nl_growth"] = nl_growth["ln_radiance"] - nl_growth["ln_radiance_lag4"]

# Merge with firms via province
firm_nl = firm_locations[["ticker", "province"]].merge(
    nl_growth[["province", "year", "quarter", "nl_growth"]],
    on="province",
    how="inner"
)

# Merge with stock returns
monthly_returns = dc.get_monthly_returns(
    start_date="2014-01-01",
    end_date="2024-12-31"
)
monthly_returns["year"] = monthly_returns["date"].dt.year
monthly_returns["quarter"] = monthly_returns["date"].dt.quarter

returns_with_nl = monthly_returns.merge(
    firm_nl,
    on=["ticker", "year", "quarter"],
    how="inner"
)
Hiện mã
# Portfolio sort: quintiles on provincial nightlight growth
returns_with_nl["nl_quintile"] = returns_with_nl.groupby("date")[
    "nl_growth"
].transform(lambda x: pd.qcut(x, 5, labels=[1, 2, 3, 4, 5],
                                duplicates="drop"))

nl_port_returns = (
    returns_with_nl.groupby(["date", "nl_quintile"])
    .agg(port_ret=("ret", "mean"))
    .reset_index()
)

nl_wide = nl_port_returns.pivot(
    index="date", columns="nl_quintile", values="port_ret"
)
nl_wide["L-S"] = nl_wide[5] - nl_wide[1]  # High NL growth - Low
Bảng 46.2: Lợi nhuận của danh mục theo nhóm ngũ phân vị tăng trưởng độ sáng ban đêm
Hiện mã
nl_summary = nl_wide.describe().T[["mean", "std"]].copy()
nl_summary["mean_ann"] = nl_summary["mean"] * 12
nl_summary["sharpe"] = (
    nl_summary["mean_ann"] / (nl_summary["std"] * np.sqrt(12))
)
for col in nl_wide.columns:
    t_stat = nl_wide[col].mean() / (
        nl_wide[col].std() / np.sqrt(len(nl_wide.dropna()))
    )
    nl_summary.loc[col, "t_stat"] = t_stat

nl_summary = nl_summary[["mean_ann", "sharpe", "t_stat"]].round(4)
nl_summary.columns = ["Ann. Return", "Sharpe", "t-stat"]
nl_summary

46.2.3 Ứng dụng 2: Ảnh vệ tinh cho dự báo tức thời theo ngành

Ngoài độ sáng ban đêm, ảnh vệ tinh ban ngày cung cấp các tín hiệu đặc thù theo ngành. Chúng ta triển khai ba kênh liên quan đến nền kinh tế Việt Nam.

Port activity. Vietnam is a major export-oriented economy. Satellite imagery of container ports (Cát Lái, Hải Phòng) captures trade throughput before customs statistics are released. Ship detection algorithms applied to synthetic aperture radar (SAR) imagery count vessels and estimate cargo volumes.

Tiến độ xây dựng. Bất động sản và xây dựng chiếm một tỷ trọng đáng kể trong GDP và vốn hóa thị trường của Việt Nam. Các thuật toán phát hiện thay đổi áp dụng cho ảnh quang học độ phân giải cao nhận diện các công trình khởi công, tỷ lệ hoàn thành và việc chuyển đổi mục đích sử dụng đất.

Giám sát nông nghiệp. Việt Nam là nước xuất khẩu hàng đầu về gạo, cà phê, cao su và thủy sản. Chỉ số khác biệt thực vật chuẩn hóa (NDVI), được tính từ dữ liệu vệ tinh đa phổ, cung cấp đánh giá về sức khỏe cây trồng:

\[ \text{NDVI} = \frac{\rho_{\text{NIR}} - \rho_{\text{Red}}}{\rho_{\text{NIR}} + \rho_{\text{Red}}} \tag{46.2}\]

trong đó \(\rho_{\text{NIR}}\) và \(\rho_{\text{Red}}\) là độ phản xạ ở các băng cận hồng ngoại và băng đỏ. NDVI nhận giá trị từ \(-1\) đến \(+1\), với các giá trị trên 0,3 cho thấy thực vật khỏe mạnh. Các độ lệch so với chuẩn theo mùa là biến đại diện cho các bất ngờ về năng suất cây trồng.

Hiện mã
# Load NDVI data for Vietnamese agricultural regions
# Source: MODIS/Terra (MOD13Q1, 250m resolution, 16-day composites)
ndvi_data = dc.get_ndvi_data(
    start_date="2014-01-01",
    end_date="2024-12-31",
    regions=["mekong_delta", "central_highlands",
             "red_river_delta", "southeast"]
)

# Compute NDVI anomaly: deviation from 5-year seasonal average
ndvi_data["month"] = ndvi_data["date"].dt.month

seasonal_mean = (
    ndvi_data.groupby(["region", "month"])
    ["mean_ndvi"].transform(
        lambda x: x.rolling(5 * 12, min_periods=12).mean()
    )
)
ndvi_data["ndvi_anomaly"] = ndvi_data["mean_ndvi"] - seasonal_mean

# Agricultural sector firms
agri_firms = dc.get_firms_by_sector(sector="agriculture")

# Link NDVI anomaly to agricultural firm returns
agri_returns = monthly_returns[
    monthly_returns["ticker"].isin(agri_firms["ticker"])
].copy()

agri_returns["month"] = agri_returns["date"].dt.month
agri_returns["year"] = agri_returns["date"].dt.year

# Regional NDVI aggregation (Mekong Delta for rice firms, etc.)
mekong_ndvi = ndvi_data[ndvi_data["region"] == "mekong_delta"].copy()
mekong_ndvi["year"] = mekong_ndvi["date"].dt.year
mekong_ndvi["month"] = mekong_ndvi["date"].dt.month

mekong_monthly = (
    mekong_ndvi.groupby(["year", "month"])
    .agg(ndvi_anomaly=("ndvi_anomaly", "mean"))
    .reset_index()
)
Hiện mã
ndvi_plot = ndvi_data[ndvi_data["region"] == "mekong_delta"].copy()

(
    p9.ggplot(ndvi_plot, p9.aes(x="date", y="ndvi_anomaly"))
    + p9.geom_line(color="#27AE60", alpha=0.5, size=0.4)
    + p9.geom_smooth(method="lowess", color="#2E5090", size=1, se=False)
    + p9.geom_hline(yintercept=0, linetype="dashed", color="gray")
    + p9.labs(
        x="",
        y="NDVI Anomaly",
        title="Mekong Delta Vegetation Health: Deviation from Seasonal Norm"
    )
    + p9.theme_minimal()
    + p9.theme(figure_size=(12, 5))
)
Hình 46.2
Hiện mã
# Panel regression: agricultural firm returns on NDVI anomaly
agri_panel = agri_returns.merge(
    mekong_monthly,
    on=["year", "month"],
    how="inner"
)

# Lagged NDVI anomaly (one month)
agri_panel = agri_panel.sort_values(["ticker", "date"])
agri_panel["ndvi_lag1"] = agri_panel.groupby(
    "ticker"
)["ndvi_anomaly"].shift(1)

agri_clean = agri_panel.dropna(
    subset=["ret", "ndvi_lag1"]
).set_index(["ticker", "date"])

model_ndvi = PanelOLS(
    agri_clean["ret"],
    agri_clean[["ndvi_lag1"]],
    entity_effects=True,
    time_effects=True,
    check_rank=False
).fit(cov_type="clustered", cluster_entity=True)

agri_clean = agri_clean.reset_index()

print(f"NDVI → Agricultural Returns:")
print(f"  β(NDVI_lag): {model_ndvi.params['ndvi_lag1']:.4f}")
print(f"  t-stat: {model_ndvi.tstats['ndvi_lag1']:.3f}")
print(f"  R² (within): {model_ndvi.rsquared_within:.4f}")

46.2.4 Trích xuất đặc trưng từ ảnh vệ tinh bằng CNN

Đối với ảnh vệ tinh thô (thay vì các chỉ số tính sẵn như NDVI), chúng ta dùng học chuyển giao từ các CNN để trích xuất các đặc trưng không gian. Cách tiếp cận theo Jean và c.s. (2016): dùng một CNN đã tiền huấn luyện trên ImageNet để trích xuất các vector đặc trưng từ các ô ảnh vệ tinh, sau đó hồi quy các kết quả kinh tế theo các đặc trưng này.

Hiện mã
def satellite_feature_pipeline(image_dir, model_name="resnet50"):
    """
    Extract CNN features from satellite image tiles.

    Parameters
    ----------
    image_dir : str or Path
        Directory containing satellite tiles (PNG/TIFF).
    model_name : str
        Pre-trained model to use.

    Returns
    -------
    DataFrame : image_id, feature vector columns.
    """
    image_dir = Path(image_dir)
    image_paths = sorted(image_dir.glob("*.png")) + sorted(
        image_dir.glob("*.tif")
    )

    if not image_paths:
        print("No images found.")
        return pd.DataFrame()

    device = "cuda" if torch.cuda.is_available() else "cpu"
    model, transform, dim = build_feature_extractor(model_name, device)

    features = extract_features(image_paths, model, transform, device)

    # Create DataFrame
    feature_cols = [f"feat_{i}" for i in range(dim)]
    df = pd.DataFrame(features, columns=feature_cols)
    df["image_id"] = [p.stem for p in image_paths]

    return df


def predict_economic_activity(features_df, labels_df, label_col,
                              n_components=50):
    """
    Predict economic activity from satellite image features.

    Uses PCA for dimensionality reduction, then ridge regression.

    Parameters
    ----------
    features_df : DataFrame
        CNN features with image_id.
    labels_df : DataFrame
        Economic outcomes with image_id.
    label_col : str
        Target variable column name.
    n_components : int
        PCA components to retain.

    Returns
    -------
    dict : R², coefficients, cross-validated performance.
    """
    from sklearn.decomposition import PCA
    from sklearn.linear_model import RidgeCV
    from sklearn.model_selection import cross_val_score

    merged = features_df.merge(labels_df, on="image_id")
    feature_cols = [c for c in features_df.columns if c.startswith("feat_")]

    X = merged[feature_cols].values
    y = merged[label_col].values

    # PCA
    pca = PCA(n_components=n_components)
    X_pca = pca.fit_transform(X)
    var_explained = pca.explained_variance_ratio_.sum()

    # Ridge regression with cross-validation
    ridge = RidgeCV(alphas=np.logspace(-3, 3, 20), cv=5)
    cv_scores = cross_val_score(ridge, X_pca, y, cv=5, scoring="r2")

    ridge.fit(X_pca, y)

    return {
        "r2_cv_mean": cv_scores.mean(),
        "r2_cv_std": cv_scores.std(),
        "r2_train": ridge.score(X_pca, y),
        "pca_var_explained": var_explained,
        "optimal_alpha": ridge.alpha_,
        "n_images": len(merged)
    }

46.3 Phân tích ảnh tài liệu

46.3.1 Vấn đề hồ sơ tài chính của Việt Nam

Một tỷ lệ đáng kể các bản công bố thông tin của doanh nghiệp Việt Nam (ví dụ: báo cáo thường niên, báo cáo tài chính, nghị quyết hội đồng quản trị, biên bản họp cổ đông) được phát hành dưới dạng ảnh PDF quét thay vì văn bản máy đọc được. Điều này tạo ra một nút thắt trong trích xuất dữ liệu: thông tin tồn tại nhưng bị mắc kẹt trong định dạng điểm ảnh. Không như các hồ sơ ở những thị trường phát triển hơn (nơi các quy định bắt buộc XBRL bảo đảm khả năng máy đọc được), các hồ sơ của Việt Nam đòi hỏi nhận dạng ký tự quang học (OCR) và phân tích bố cục trước khi có thể bắt đầu bất kỳ phân tích định lượng nào.

Quy trình AI xử lý tài liệu cho các hồ sơ tài chính Việt Nam gồm bốn giai đoạn:

  1. Phân loại trang: Xác định những trang nào chứa báo cáo tài chính, thảo luận của ban điều hành, ý kiến kiểm toán, v.v.
  2. Phân tích bố cục: Phát hiện cấu trúc không gian như tiêu đề, đoạn văn, bảng, hình vẽ, chú thích.
  3. OCR: Chuyển các vùng ảnh thành văn bản, dùng các mô hình được tối ưu cho tiếng Việt.
  4. Trích xuất có cấu trúc: Phân tích cú pháp văn bản đã nhận dạng thành dữ liệu có cấu trúc (ví dụ: số liệu doanh thu, các khoản mục bảng cân đối kế toán).

46.3.2 OCR cho tài liệu tài chính tiếng Việt

Standard OCR engines (Tesseract, Google Cloud Vision) struggle with Vietnamese financial documents due to the combination of Vietnamese diacritics (ă, ơ, ư, ê, etc.), mixed Vietnamese-English content, and complex table layouts. We implement a pipeline using PaddleOCR (which has strong CJK and Southeast Asian language support) and VietOCR (a Vietnamese-specific model based on the transformer architecture of Baek và c.s. (2019)).

Hiện mã
def ocr_financial_document(pdf_path, language="vi",
                           engine="paddleocr"):
    """
    OCR a Vietnamese financial document (scanned PDF).

    Parameters
    ----------
    pdf_path : str
        Path to PDF file.
    language : str
        Language code.
    engine : str
        'paddleocr' or 'vietocr'.

    Returns
    -------
    list[dict] : Per-page OCR results with bounding boxes.
    """
    from pdf2image import convert_from_path

    # Convert PDF pages to images
    pages = convert_from_path(pdf_path, dpi=300)

    results = []

    if engine == "paddleocr":
        from paddleocr import PaddleOCR
        ocr = PaddleOCR(use_angle_cls=True, lang="vi", use_gpu=False)

        for page_num, page_img in enumerate(pages):
            # Convert PIL to numpy
            img_array = np.array(page_img)
            ocr_result = ocr.ocr(img_array, cls=True)

            page_texts = []
            for line in ocr_result[0]:
                bbox, (text, confidence) = line
                page_texts.append({
                    "text": text,
                    "confidence": confidence,
                    "bbox": bbox,
                    "page": page_num + 1
                })

            results.extend(page_texts)

    return results


def classify_page_type(ocr_results, page_num):
    """
    Classify a document page by content type using keyword matching.

    Returns one of: 'balance_sheet', 'income_statement',
    'cash_flow', 'notes', 'audit', 'management', 'other'.
    """
    page_text = " ".join(
        [r["text"] for r in ocr_results if r["page"] == page_num]
    ).lower()

    # Vietnamese financial statement keywords
    keyword_map = {
        "balance_sheet": [
            "bảng cân đối kế toán", "tài sản", "nguồn vốn",
            "nợ phải trả", "vốn chủ sở hữu"
        ],
        "income_statement": [
            "kết quả hoạt động kinh doanh", "doanh thu",
            "lợi nhuận", "chi phí", "thu nhập"
        ],
        "cash_flow": [
            "lưu chuyển tiền tệ", "dòng tiền",
            "hoạt động kinh doanh", "hoạt động đầu tư"
        ],
        "audit": [
            "báo cáo kiểm toán", "kiểm toán viên",
            "ý kiến kiểm toán", "trung thực và hợp lý"
        ],
        "management": [
            "ban giám đốc", "hội đồng quản trị",
            "báo cáo thường niên", "tình hình hoạt động"
        ]
    }

    scores = {}
    for page_type, keywords in keyword_map.items():
        scores[page_type] = sum(
            1 for kw in keywords if kw in page_text
        )

    if max(scores.values()) == 0:
        return "other"
    return max(scores, key=scores.get)

46.3.3 Trích xuất bảng từ báo cáo tài chính

Tác vụ trích xuất có giá trị cao nhất là khôi phục các bảng có cấu trúc từ báo cáo tài chính. Chúng ta triển khai một cách tiếp cận hai giai đoạn: trước tiên phát hiện các vùng bảng bằng một mô hình phân tích bố cục, sau đó phân tích cú pháp các vùng được phát hiện thành cấu trúc hàng-cột.

Hiện mã
def extract_tables_from_page(page_image, ocr_results, page_num):
    """
    Extract structured tables from a document page.

    Uses spatial clustering of OCR bounding boxes to identify
    table regions, then aligns text into rows and columns.

    Parameters
    ----------
    page_image : PIL.Image
        Page image.
    ocr_results : list[dict]
        OCR results for this page.
    page_num : int
        Page number.

    Returns
    -------
    list[pd.DataFrame] : Extracted tables as DataFrames.
    """
    page_texts = [r for r in ocr_results if r["page"] == page_num]

    if not page_texts:
        return []

    # Extract bounding box centers
    centers = []
    for item in page_texts:
        bbox = item["bbox"]
        # bbox is [[x1,y1],[x2,y2],[x3,y3],[x4,y4]]
        cx = np.mean([p[0] for p in bbox])
        cy = np.mean([p[1] for p in bbox])
        centers.append((cx, cy, item["text"]))

    if not centers:
        return []

    centers_df = pd.DataFrame(centers, columns=["x", "y", "text"])

    # Cluster into rows by y-coordinate proximity
    centers_df = centers_df.sort_values("y")
    row_threshold = 15  # pixels
    centers_df["row_id"] = (
        centers_df["y"].diff().abs() > row_threshold
    ).cumsum()

    # Within each row, sort by x-coordinate
    tables = []
    rows = []
    for row_id, row_group in centers_df.groupby("row_id"):
        row_sorted = row_group.sort_values("x")
        rows.append(row_sorted["text"].tolist())

    if len(rows) > 2:
        # Attempt to construct DataFrame
        max_cols = max(len(r) for r in rows)
        # Pad shorter rows
        padded = [r + [""] * (max_cols - len(r)) for r in rows]

        try:
            df = pd.DataFrame(padded[1:], columns=padded[0])
            tables.append(df)
        except Exception:
            tables.append(pd.DataFrame(padded))

    return tables


def parse_financial_numbers(text):
    """
    Parse Vietnamese financial number formats.
    Vietnamese uses dots as thousands separators and commas as decimals.
    E.g., '1.234.567' = 1234567, '1.234,56' = 1234.56
    """
    import re
    text = text.strip().replace(" ", "")

    # Remove parentheses (negative indicator)
    negative = text.startswith("(") and text.endswith(")")
    text = text.strip("()")

    # Handle Vietnamese number format
    # If comma is present, it's a decimal separator
    if "," in text:
        text = text.replace(".", "").replace(",", ".")
    else:
        text = text.replace(".", "")

    try:
        value = float(text)
        return -value if negative else value
    except ValueError:
        return np.nan

46.3.4 Hiểu tài liệu có nhận biết bố cục

AI xử lý tài liệu hiện đại vượt ra ngoài OCR bằng cách mô hình hóa đồng thời nội dung văn bản và bố cục không gian. LayoutLM (Huang và c.s. 2022) và các mô hình kế tiếp coi mỗi token có cả vector nhúng văn bản lẫn vector nhúng vị trí được suy ra từ tọa độ khung bao của nó. Điều này cho phép mô hình hiểu rằng một con số nằm bên dưới tiêu đề “Revenue” và bên phải của “2023” là số liệu doanh thu năm 2023, ngay cả khi không có phát hiện bảng một cách tường minh.

Hiện mã
def layoutlm_extract(document_pages, model_name="layoutlmv3"):
    """
    Extract structured financial data using LayoutLM.

    This function uses the pre-trained LayoutLMv3 model for
    document understanding with Vietnamese financial statements.

    Parameters
    ----------
    document_pages : list
        List of (page_image, ocr_results) tuples.
    model_name : str
        Model variant.

    Returns
    -------
    dict : Extracted financial fields.
    """
    from transformers import (
        LayoutLMv3ForTokenClassification,
        LayoutLMv3Processor
    )

    processor = LayoutLMv3Processor.from_pretrained(
        "microsoft/layoutlmv3-base",
        apply_ocr=False  # We provide our own OCR
    )

    model = LayoutLMv3ForTokenClassification.from_pretrained(
        "microsoft/layoutlmv3-base",
        num_labels=13  # Financial statement field types
    )

    # Define target fields for extraction
    field_labels = [
        "O",  # Other
        "B-REVENUE", "I-REVENUE",
        "B-COGS", "I-COGS",
        "B-NET_INCOME", "I-NET_INCOME",
        "B-TOTAL_ASSETS", "I-TOTAL_ASSETS",
        "B-TOTAL_EQUITY", "I-TOTAL_EQUITY",
        "B-TOTAL_DEBT", "I-TOTAL_DEBT"
    ]

    extracted = {}

    for page_img, ocr_results in document_pages:
        words = [r["text"] for r in ocr_results]
        boxes = []
        for r in ocr_results:
            bbox = r["bbox"]
            # Normalize to 0-1000 range
            x0 = min(p[0] for p in bbox)
            y0 = min(p[1] for p in bbox)
            x1 = max(p[0] for p in bbox)
            y1 = max(p[1] for p in bbox)
            boxes.append([int(x0), int(y0), int(x1), int(y1)])

        if not words:
            continue

        # Process through LayoutLM
        encoding = processor(
            page_img,
            words,
            boxes=boxes,
            return_tensors="pt",
            truncation=True,
            max_length=512
        )

        with torch.no_grad():
            outputs = model(**encoding)

        predictions = outputs.logits.argmax(-1).squeeze().tolist()

        # Extract labeled entities
        for idx, pred in enumerate(predictions):
            if pred > 0 and idx < len(words):
                label = field_labels[pred]
                if label.startswith("B-"):
                    field = label[2:]
                    value = parse_financial_numbers(words[idx])
                    if not np.isnan(value):
                        extracted[field] = value

    return extracted

46.4 Số hóa biểu đồ và hình vẽ

46.4.1 Động cơ: Vì sao hình ảnh mang thông tin tài chính

Các biểu đồ tài chính (ví dụ: chuỗi thời gian giá, biểu đồ cột về lợi nhuận kế toán, biểu đồ phân tán về đánh đổi rủi ro-lợi nhuận) chứa thông tin mà các nhà phân tích xử lý bằng mắt. Đối với các chiến lược có hệ thống, thông tin này phải được chuyển sang dạng số. Ba trường hợp sử dụng thúc đẩy việc số hóa biểu đồ:

  1. Khôi phục dữ liệu lịch sử. Dữ liệu tài chính thời kỳ tiền số hóa thường chỉ tồn tại trong các biểu đồ in. Việc số hóa các biểu đồ này kéo dài các chuỗi thời gian lịch sử vượt ra ngoài kỷ nguyên điện tử.
  2. Trích xuất báo cáo của công ty môi giới. Các báo cáo phân tích bên bán chứa các biểu đồ với dự phóng và phân tích kịch bản. Việc trích xuất chúng bằng chương trình cho phép tổng hợp có hệ thống quan điểm của các nhà phân tích.
  3. Hồ sơ quy định. Các hồ sơ quy định của Việt Nam đôi khi nhúng dữ liệu dưới dạng hình ảnh (biểu đồ, bảng quét) thay vì các giá trị máy đọc được.

46.4.2 Phân loại kiểu biểu đồ

Bước đầu tiên là phân loại kiểu biểu đồ (đường, cột, phân tán, tròn, nến), từ đó xác định thuật toán số hóa phù hợp.

Hiện mã
def build_chart_classifier(n_classes=5):
    """
    Build a CNN-based chart type classifier.

    Classes: line_chart, bar_chart, scatter_plot,
             candlestick, pie_chart.
    """
    model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1)

    # Replace final layer for chart classification
    model.fc = nn.Sequential(
        nn.Dropout(0.3),
        nn.Linear(512, n_classes)
    )

    return model


def classify_chart(image_path, model, transform):
    """Classify a chart image into one of 5 types."""
    class_names = [
        "line_chart", "bar_chart", "scatter_plot",
        "candlestick", "pie_chart"
    ]

    img = Image.open(image_path).convert("RGB")
    tensor = transform(img).unsqueeze(0)

    with torch.no_grad():
        logits = model(tensor)
        probs = torch.softmax(logits, dim=1).squeeze()

    pred_idx = probs.argmax().item()
    return {
        "predicted_class": class_names[pred_idx],
        "confidence": probs[pred_idx].item(),
        "all_probs": {
            name: probs[i].item()
            for i, name in enumerate(class_names)
        }
    }

46.4.3 Số hóa biểu đồ đường

Đối với biểu đồ đường, nhiệm vụ số hóa là khôi phục chuỗi dữ liệu \((x, y)\) từ hình ảnh. Quy trình bao gồm phát hiện trục, hiệu chỉnh thang đo và dò đường cong.

Hiện mã
def digitize_line_chart(image_path, x_range=None, y_range=None):
    """
    Digitize a line chart image to recover the data series.

    Parameters
    ----------
    image_path : str
        Path to chart image.
    x_range : tuple, optional
        (x_min, x_max) if known.
    y_range : tuple, optional
        (y_min, y_max) if known.

    Returns
    -------
    DataFrame : Digitized data points (x, y).
    """
    import cv2

    img = cv2.imread(str(image_path))
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    h, w = gray.shape

    # Step 1: Detect plot area (largest rectangular region)
    edges = cv2.Canny(gray, 50, 150)
    contours, _ = cv2.findContours(
        edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
    )

    if contours:
        largest = max(contours, key=cv2.contourArea)
        x_start, y_start, plot_w, plot_h = cv2.boundingRect(largest)
    else:
        # Fallback: assume plot is central 80% of image
        x_start, y_start = int(w * 0.1), int(h * 0.1)
        plot_w, plot_h = int(w * 0.8), int(h * 0.8)

    # Step 2: Extract line pixels within plot area
    # Convert to HSV and isolate colored lines
    hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
    plot_region = hsv[y_start:y_start + plot_h,
                      x_start:x_start + plot_w]

    # Detect non-white, non-gray pixels (likely the line)
    saturation = plot_region[:, :, 1]
    line_mask = saturation > 30  # Colored pixels

    # Step 3: Trace the line (column-wise median of colored pixels)
    data_points = []
    for col in range(plot_w):
        col_pixels = np.where(line_mask[:, col])[0]
        if len(col_pixels) > 0:
            # Use median y-position
            y_pixel = np.median(col_pixels)

            # Convert pixel to data coordinates
            x_frac = col / plot_w
            y_frac = 1 - y_pixel / plot_h  # Invert y-axis

            x_val = (x_range[0] + x_frac * (x_range[1] - x_range[0])
                     if x_range else x_frac)
            y_val = (y_range[0] + y_frac * (y_range[1] - y_range[0])
                     if y_range else y_frac)

            data_points.append({"x": x_val, "y": y_val})

    return pd.DataFrame(data_points)

46.5 Phân tích cảm xúc thị giác

46.5.1 Cảm xúc của hình ảnh trong tin tức tài chính

Các bài báo tin tức đi kèm các hình ảnh mang cảm xúc độc lập với văn bản. Bức ảnh một CEO mỉm cười tại buổi họp báo truyền tải thông tin khác với bức ảnh cùng CEO đó đối mặt với người biểu tình. Obaid và Pukthuanthong (2022) chứng minh rằng cảm xúc thị giác của các bức ảnh trên Wall Street Journal dự báo lợi nhuận thị trường: những ngày có nhiều hình ảnh tiêu cực hơn đi trước mức lợi nhuận thấp hơn.

Chúng ta triển khai phân tích cảm xúc thị giác bằng hai cách tiếp cận: một bộ phân loại cảm xúc tiền huấn luyện và một mô hình thị giác-ngôn ngữ diễn giải hình ảnh trong bối cảnh tài chính.

46.5.2 Cảm xúc thị giác dựa trên CNN

Hiện mã
def compute_visual_sentiment(image_paths, model_name="resnet50"):
    """
    Compute visual sentiment scores using a fine-tuned CNN.

    Uses features from a pre-trained CNN followed by a sentiment
    classifier trained on the Visual Sentiment Ontology (VSO)
    or similar dataset.

    Parameters
    ----------
    image_paths : list
        Paths to news images.

    Returns
    -------
    DataFrame : image_path, positive_score, negative_score, sentiment.
    """
    device = "cuda" if torch.cuda.is_available() else "cpu"
    model, transform, dim = build_feature_extractor(model_name, device)

    # Extract features
    features = extract_features(image_paths, model, transform, device)

    # Simple sentiment model: use mean activation as proxy
    # (In practice, fine-tune on labeled financial images)
    # Higher mean activation in certain feature channels
    # correlates with positive/negative affect

    # Positive channels (empirically determined via validation)
    pos_channels = list(range(0, dim // 3))
    neg_channels = list(range(dim // 3, 2 * dim // 3))

    pos_scores = features[:, pos_channels].mean(axis=1)
    neg_scores = features[:, neg_channels].mean(axis=1)

    # Normalize to [0, 1]
    pos_norm = (pos_scores - pos_scores.min()) / (
        pos_scores.max() - pos_scores.min() + 1e-8
    )
    neg_norm = (neg_scores - neg_scores.min()) / (
        neg_scores.max() - neg_scores.min() + 1e-8
    )

    sentiment = pos_norm - neg_norm

    return pd.DataFrame({
        "image_path": image_paths,
        "positive_score": pos_norm,
        "negative_score": neg_norm,
        "net_sentiment": sentiment
    })

46.5.3 Mô hình thị giác-ngôn ngữ cho việc hiểu hình ảnh tài chính

Cách tiếp cận mạnh nhất cho phân tích hình ảnh tài chính sử dụng các mô hình thị giác-ngôn ngữ (VLM), xử lý đồng thời hình ảnh và văn bản. Các mô hình như CLIP (Radford và c.s. 2021), BLIP-2 (Li và c.s. 2023) và GPT-4V có thể được nhắc lệnh để diễn giải các hình ảnh tài chính trong ngữ cảnh. Chẳng hạn, với một bức ảnh chụp từ trên không của một nhà máy, một VLM có thể trả lời “Nhà máy này có đang hoạt động hết công suất không?” hoặc “Có thấy việc xây dựng thêm các cơ sở không?”

Hiện mã
def vlm_financial_analysis(image_path, prompt, model_name="clip"):
    """
    Use a vision-language model to analyze a financial image.

    Parameters
    ----------
    image_path : str
        Path to image.
    prompt : str
        Financial analysis prompt.
    model_name : str
        'clip' for zero-shot classification,
        'blip2' for visual question answering.

    Returns
    -------
    dict : Model output (scores or text).
    """
    img = Image.open(image_path).convert("RGB")

    if model_name == "clip":
        from transformers import CLIPProcessor, CLIPModel

        clip_model = CLIPModel.from_pretrained(
            "openai/clip-vit-base-patch32"
        )
        processor = CLIPProcessor.from_pretrained(
            "openai/clip-vit-base-patch32"
        )

        # Zero-shot classification with financial labels
        labels = [
            "busy commercial area with many customers",
            "empty commercial area with few customers",
            "active construction site with workers",
            "idle construction site without activity",
            "healthy green crops in agricultural field",
            "damaged or dry crops in agricultural field",
            "busy port with many ships and containers",
            "quiet port with few ships"
        ]

        inputs = processor(
            text=labels,
            images=img,
            return_tensors="pt",
            padding=True
        )

        with torch.no_grad():
            outputs = clip_model(**inputs)
            logits = outputs.logits_per_image.squeeze()
            probs = torch.softmax(logits, dim=0)

        results = {
            label: prob.item()
            for label, prob in zip(labels, probs)
        }

        return {"scores": results, "top_label": max(results, key=results.get)}

    elif model_name == "blip2":
        from transformers import Blip2Processor, Blip2ForConditionalGeneration

        processor = Blip2Processor.from_pretrained(
            "Salesforce/blip2-opt-2.7b"
        )
        model = Blip2ForConditionalGeneration.from_pretrained(
            "Salesforce/blip2-opt-2.7b",
            torch_dtype=torch.float16
        )

        inputs = processor(images=img, text=prompt, return_tensors="pt")

        with torch.no_grad():
            generated_ids = model.generate(**inputs, max_length=100)
            answer = processor.decode(
                generated_ids[0], skip_special_tokens=True
            )

        return {"answer": answer}
Hiện mã
# Construct daily visual sentiment index from news images
# Source: Vietnamese financial news sites (VnExpress, CafeF, etc.)
news_images = dc.get_news_images(
    start_date="2018-01-01",
    end_date="2024-12-31",
    source=["vnexpress_finance", "cafef"]
)

# Aggregate daily visual sentiment
daily_sentiment = (
    news_images.groupby("date")
    .agg(
        visual_sentiment=("net_sentiment", "mean"),
        n_images=("net_sentiment", "count"),
        pct_negative=("net_sentiment", lambda x: (x < 0).mean())
    )
    .reset_index()
)

# Merge with market returns
market_returns = dc.get_market_returns(
    start_date="2018-01-01",
    end_date="2024-12-31",
    frequency="daily"
)

sentiment_returns = daily_sentiment.merge(
    market_returns[["date", "mkt_ret"]],
    on="date",
    how="inner"
)

# Lead-lag analysis: does visual sentiment predict next-day returns?
sentiment_returns = sentiment_returns.sort_values("date")
sentiment_returns["mkt_ret_lead1"] = sentiment_returns["mkt_ret"].shift(-1)
Bảng 46.3: Cảm xúc thị giác và khả năng dự báo lợi nhuận thị trường
Hiện mã
# Regression: next-day return on today's visual sentiment
sr_clean = sentiment_returns.dropna(
    subset=["mkt_ret_lead1", "visual_sentiment", "mkt_ret"]
)

model_sent = sm.OLS(
    sr_clean["mkt_ret_lead1"],
    sm.add_constant(sr_clean[["visual_sentiment", "mkt_ret"]])
).fit(cov_type="HAC", cov_kwds={"maxlags": 5})

sent_results = pd.DataFrame({
    "Coefficient": model_sent.params.round(6),
    "Std Error": model_sent.bse.round(6),
    "t-stat": model_sent.tvalues.round(3),
    "p-value": model_sent.pvalues.round(4)
})
sent_results

46.6 Hợp nhất đa phương thức: Kết hợp hình ảnh và văn bản

46.6.1 Vì sao cần đa phương thức?

Văn bản và hình ảnh nắm bắt các khía cạnh khác nhau của cùng một thực tế kinh tế nền tảng. Một báo cáo kết quả kinh doanh mô tả hiệu quả tài chính bằng lời và số; các bức ảnh đi kèm cho thấy nhà máy, sản phẩm và ban điều hành. Một bài báo về một cảng mô tả khối lượng thương mại bằng văn bản; ảnh vệ tinh cho thấy vị trí thực tế của các tàu. Kết hợp cả hai phương thức cho ra một biểu diễn phong phú hơn so với từng phương thức riêng lẻ.

Kiến trúc hợp nhất phụ thuộc vào ứng dụng:

Hợp nhất sớm. Nối các đặc trưng hình ảnh \(\mathbf{z}^{\text{img}}\) và các đặc trưng văn bản \(\mathbf{z}^{\text{txt}}\) thành một vector duy nhất \([\mathbf{z}^{\text{img}}; \mathbf{z}^{\text{txt}}]\) trước khi dự báo. Đơn giản nhưng bỏ qua các tương tác chéo phương thức.

Hợp nhất muộn. Huấn luyện các mô hình riêng cho từng phương thức và kết hợp các dự báo: \(\hat{y} = \alpha \hat{y}^{\text{img}} + (1-\alpha) \hat{y}^{\text{txt}}\). Vững chắc nhưng không thể học các đặc trưng chéo phương thức.

Hợp nhất bằng chú ý chéo. Dùng cơ chế chú ý chéo (cross-attention) của transformer để mỗi phương thức chú ý đến phương thức còn lại. Mạnh nhất nhưng đòi hỏi nhiều dữ liệu và tính toán hơn.

\[ \mathbf{z}^{\text{fused}} = \text{CrossAttention}(\mathbf{z}^{\text{img}}, \mathbf{z}^{\text{txt}}) = \text{softmax}\left(\frac{\mathbf{Q}^{\text{img}} (\mathbf{K}^{\text{txt}})^\top}{\sqrt{d}}\right) \mathbf{V}^{\text{txt}} \tag{46.3}\]

Hiện mã
class MultimodalFusionModel(nn.Module):
    """
    Multimodal fusion model combining image and text features
    for financial prediction.

    Supports early fusion, late fusion, and cross-attention.
    """

    def __init__(self, img_dim=2048, txt_dim=768, hidden_dim=256,
                 fusion="early", n_heads=4):
        super().__init__()
        self.fusion = fusion

        # Image projection
        self.img_proj = nn.Sequential(
            nn.Linear(img_dim, hidden_dim),
            nn.ReLU(),
            nn.Dropout(0.2)
        )

        # Text projection
        self.txt_proj = nn.Sequential(
            nn.Linear(txt_dim, hidden_dim),
            nn.ReLU(),
            nn.Dropout(0.2)
        )

        if fusion == "early":
            self.head = nn.Sequential(
                nn.Linear(hidden_dim * 2, hidden_dim),
                nn.ReLU(),
                nn.Dropout(0.2),
                nn.Linear(hidden_dim, 1)
            )
        elif fusion == "late":
            self.img_head = nn.Linear(hidden_dim, 1)
            self.txt_head = nn.Linear(hidden_dim, 1)
            self.alpha = nn.Parameter(torch.tensor(0.5))
        elif fusion == "cross_attention":
            self.cross_attn = nn.MultiheadAttention(
                embed_dim=hidden_dim,
                num_heads=n_heads,
                batch_first=True
            )
            self.head = nn.Sequential(
                nn.Linear(hidden_dim, hidden_dim // 2),
                nn.ReLU(),
                nn.Linear(hidden_dim // 2, 1)
            )

    def forward(self, img_features, txt_features):
        img_h = self.img_proj(img_features)
        txt_h = self.txt_proj(txt_features)

        if self.fusion == "early":
            combined = torch.cat([img_h, txt_h], dim=-1)
            return self.head(combined).squeeze(-1)

        elif self.fusion == "late":
            img_pred = self.img_head(img_h).squeeze(-1)
            txt_pred = self.txt_head(txt_h).squeeze(-1)
            alpha = torch.sigmoid(self.alpha)
            return alpha * img_pred + (1 - alpha) * txt_pred

        elif self.fusion == "cross_attention":
            # Image attends to text
            img_h_unsq = img_h.unsqueeze(1)  # (B, 1, D)
            txt_h_unsq = txt_h.unsqueeze(1)

            attn_out, _ = self.cross_attn(
                img_h_unsq, txt_h_unsq, txt_h_unsq
            )
            return self.head(attn_out.squeeze(1)).squeeze(-1)
Hiện mã
def run_multimodal_experiment(image_features, text_features, returns,
                               fusion_types=["early", "late",
                                             "cross_attention"]):
    """
    Compare multimodal fusion strategies for return prediction.

    Parameters
    ----------
    image_features : np.ndarray
        Image feature matrix (N x img_dim).
    text_features : np.ndarray
        Text feature matrix (N x txt_dim).
    returns : np.ndarray
        Target returns (N,).
    fusion_types : list
        Fusion strategies to compare.

    Returns
    -------
    DataFrame : R², MSE, Sharpe for each strategy.
    """
    from sklearn.model_selection import TimeSeriesSplit

    n = len(returns)
    tscv = TimeSeriesSplit(n_splits=5)

    results = []

    for fusion in fusion_types:
        fold_r2s = []

        for train_idx, test_idx in tscv.split(returns):
            # Convert to tensors
            X_img_train = torch.tensor(
                image_features[train_idx], dtype=torch.float32
            )
            X_txt_train = torch.tensor(
                text_features[train_idx], dtype=torch.float32
            )
            y_train = torch.tensor(
                returns[train_idx], dtype=torch.float32
            )

            X_img_test = torch.tensor(
                image_features[test_idx], dtype=torch.float32
            )
            X_txt_test = torch.tensor(
                text_features[test_idx], dtype=torch.float32
            )
            y_test = returns[test_idx]

            # Build and train model
            model = MultimodalFusionModel(
                img_dim=image_features.shape[1],
                txt_dim=text_features.shape[1],
                fusion=fusion
            )

            optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
            loss_fn = nn.MSELoss()

            model.train()
            for epoch in range(50):
                optimizer.zero_grad()
                pred = model(X_img_train, X_txt_train)
                loss = loss_fn(pred, y_train)
                loss.backward()
                optimizer.step()

            # Evaluate
            model.eval()
            with torch.no_grad():
                y_pred = model(X_img_test, X_txt_test).numpy()

            ss_res = np.sum((y_test - y_pred) ** 2)
            ss_tot = np.sum((y_test - y_test.mean()) ** 2)
            r2 = 1 - ss_res / ss_tot if ss_tot > 0 else 0

            fold_r2s.append(r2)

        results.append({
            "fusion": fusion,
            "r2_mean": np.mean(fold_r2s),
            "r2_std": np.std(fold_r2s)
        })

    # Add unimodal baselines
    for modality, features in [("image_only", image_features),
                                ("text_only", text_features)]:
        from sklearn.linear_model import RidgeCV
        fold_r2s = []
        for train_idx, test_idx in tscv.split(returns):
            ridge = RidgeCV(alphas=np.logspace(-3, 3, 10))
            ridge.fit(features[train_idx], returns[train_idx])
            y_pred = ridge.predict(features[test_idx])
            y_test = returns[test_idx]
            ss_res = np.sum((y_test - y_pred) ** 2)
            ss_tot = np.sum((y_test - y_test.mean()) ** 2)
            fold_r2s.append(1 - ss_res / ss_tot if ss_tot > 0 else 0)

        results.append({
            "fusion": modality,
            "r2_mean": np.mean(fold_r2s),
            "r2_std": np.std(fold_r2s)
        })

    return pd.DataFrame(results)

46.6.2 Những cân nhắc thực tiễn đối với thị trường Việt Nam

Phân tích đa phương thức trên thị trường Việt Nam đối mặt với một số cân nhắc thực tiễn:

Căn chỉnh dữ liệu. Ảnh vệ tinh, bài báo tin tức và dữ liệu thị trường vận hành ở các tần suất thời gian và độ phân giải không gian khác nhau. Ảnh vệ tinh tổng hợp có sẵn hằng tuần hoặc hai tuần một lần; tin tức theo ngày; giao dịch trong ngày. Việc căn chỉnh đúng đòi hỏi xác định tập thông tin mà nhà đầu tư có tại thời điểm ra quyết định giao dịch để tránh thiên lệch nhìn trước.

Khan hiếm nhãn. Học có giám sát đòi hỏi dữ liệu có gán nhãn (ví dụ: hình ảnh được chú thích bằng các kết quả kinh tế). Ở Việt Nam, các nhãn thực địa (doanh số bán lẻ thực tế, năng suất cây trồng thực tế, thông lượng cảng thực tế) đến với độ trễ đáng kể và thường thiếu độ chi tiết để khớp với độ phân giải của vệ tinh. Do đó, các cách tiếp cận bán giám sát và tự giám sát là thiết yếu.

Cân nhắc về quy định. Ảnh vệ tinh độ phân giải cao của các cơ sở thương mại hoặc quân sự cụ thể có thể bị hạn chế. Các nhà nghiên cứu nên xác minh rằng các nguồn ảnh của họ tuân thủ quy định của Việt Nam về dữ liệu địa không gian.

Chi phí tính toán. Xử lý các ô ảnh vệ tinh qua CNN đòi hỏi tính toán nặng. Một ô Sentinel-2 đơn lẻ ở độ phân giải 10m bao phủ Thành phố Hồ Chí Minh chứa khoảng \(10{,}980 \times 10{,}980\) điểm ảnh trên mỗi băng tần. Việc chia thành các mảnh ảnh \(224 \times 224\) làm đầu vào cho CNN tạo ra \(\sim 2{,}400\) mảnh trên mỗi ô, mỗi mảng cần một lượt lan truyền xuôi qua mạng.

Bảng 46.4: Các nguồn dữ liệu hình ảnh cho các ứng dụng tài chính tại Việt Nam
Ứng dụng Nguồn ảnh Độ phân giải Tần suất Khả năng tiếp cận tại Việt Nam
Độ sáng ban đêm VIIRS/DMSP 500m Hằng tháng Miễn phí (NOAA/EOG)
Sức khỏe cây trồng MODIS/Sentinel-2 250m/10m 16 ngày/5 ngày Miễn phí (NASA/ESA)
Phát hiện cảng/tàu Sentinel-1 (SAR) 10m 12 ngày Miễn phí (ESA Copernicus)
Giám sát xây dựng Thương mại (Maxar) 30cm Theo yêu cầu Trả phí ($)
Mật độ đô thị Sentinel-2 10m 5 ngày Miễn phí (ESA)
OCR tài liệu Hồ sơ của doanh nghiệp N/A Theo sự kiện DataCore.vn
Ảnh tin tức Truyền thông tài chính N/A Hằng ngày Thu thập dữ liệu web

46.7 Tóm tắt

Chương này đã mở rộng bộ công cụ dữ liệu thay thế từ văn bản (chương trước) sang hình ảnh. Chúng ta đã trình bày năm lĩnh vực ứng dụng riêng biệt của dữ liệu thị giác trên thị trường tài chính Việt Nam.

Thứ nhất, ảnh vệ tinh và ảnh địa không gian cung cấp các tín hiệu kinh tế tần suất cao, chi tiết về không gian, đi trước thống kê chính thức. Độ sáng ban đêm đóng vai trò biến đại diện cho GDP cấp tỉnh với \(R^2\) theo mặt cắt ngang vượt 0.7; các chỉ số sức khỏe cây trồng NDVI dự báo lợi nhuận của các doanh nghiệp nông nghiệp; và các đặc trưng CNN được trích xuất từ các ô ảnh vệ tinh cho phép các biểu diễn không gian phong phú về hoạt động kinh tế.

Thứ hai, phân tích ảnh tài liệu giải quyết vấn đề thực tiễn là trích xuất dữ liệu có cấu trúc từ các hồ sơ tài chính của Việt Nam vốn đến dưới dạng ảnh quét. Quy trình (ví dụ: OCR với các công cụ được tối ưu cho tiếng Việt, phân tích bố cục, trích xuất bảng và hiểu tài liệu dựa trên LayoutLM) chuyển các điểm ảnh phi cấu trúc thành dữ liệu tài chính có cấu trúc mà mọi phân tích phía sau đều cần.

Thứ ba, số hóa biểu đồ khôi phục các chuỗi dữ liệu số từ các biểu diễn thị giác, mở rộng phạm vi lịch sử và cho phép sử dụng có hệ thống các sản phẩm của nhà phân tích. Thứ tư, phân tích cảm xúc thị giác từ hình ảnh tin tức cung cấp một chiều tín hiệu trực giao với cảm xúc văn bản, với sức dự báo tiềm năng đối với lợi nhuận thị trường.

Thứ năm, hợp nhất đa phương thức (kết hợp các biểu diễn hình ảnh và văn bản thông qua các kiến trúc hợp nhất sớm, muộn hoặc chú ý chéo) cho ra các mô hình dự báo phong phú hơn so với từng phương thức riêng lẻ. Lợi ích thực tiễn của các cách tiếp cận đa phương thức tăng theo mức độ đa dạng và chất lượng của dữ liệu sẵn có, khiến nó ngày càng phù hợp khi hệ sinh thái dữ liệu thay thế của Việt Nam trưởng thành.

Mạch xuyên suốt tất cả các ứng dụng là quy trình biến đổi: tensor điểm ảnh thô \(\to\) biểu diễn đặc trưng (qua CNN, ViT hoặc VLM) \(\to\) tín hiệu tài chính \(\to\) diễn giải kinh tế. Việc lựa chọn kiến trúc và chất lượng của việc thích ứng miền quyết định liệu tín hiệu thu được có nội dung dự báo thực sự hay chỉ nắm bắt nhiễu.