48  Các mô hình đa phương thức trong tài chính

Các chương trước coi văn bản và hình ảnh là những phương thức dữ liệu tách biệt, nhưng việc ra quyết định tài chính vốn dĩ là đa phương thức. Một nhà phân tích đánh giá một nhà phát triển bất động sản Việt Nam đồng thời đọc báo cáo thường niên (văn bản), xem ảnh vệ tinh các công trường xây dựng (hình ảnh), xem xét báo cáo tài chính hằng quý (dữ liệu bảng), theo dõi động thái giá và khối lượng của cổ phiếu (chuỗi thời gian), và có thể nghe cuộc họp công bố kết quả kinh doanh (âm thanh). Không phương thức đơn lẻ nào nắm bắt được toàn bộ tập thông tin. Câu hỏi mà chương này giải quyết là: liệu chúng ta có thể xây dựng các mô hình kết hợp nhiều phương thức một cách có nguyên tắc hay không, và việc kết hợp có mang lại những cải thiện có ý nghĩa kinh tế so với mô hình đơn phương thức tốt nhất hay không?

Câu trả lời từ tài liệu học máy gần đây ngày càng là có, nhưng kèm theo những lưu ý quan trọng. Các mô hình đa phương thức có thể khai thác sự bổ trợ giữa các phương thức (chẳng hạn, văn bản mô tả ý định và bối cảnh; hình ảnh phản ánh trạng thái vật lý; dữ liệu bảng cung cấp những ảnh chụp định lượng chính xác; chuỗi thời gian nắm bắt động thái). Tuy nhiên, lợi ích không tự động có. Việc nối đơn giản các đặc trưng không đồng nhất thường làm giảm hiệu suất so với mô hình đơn phương thức tốt nhất, một hiện tượng được gọi là vấn đề “lười phương thức” (modality laziness) (Huang và c.s. 2021). Việc kết hợp hiệu quả đòi hỏi các kiến trúc căn chỉnh biểu diễn giữa các phương thức, xử lý linh hoạt các phương thức bị thiếu (không phải doanh nghiệp-quý nào cũng có ảnh vệ tinh và cuộc họp công bố kết quả kinh doanh), và tránh việc phương thức chiếm ưu thế lấn át các tín hiệu yếu hơn nhưng bổ trợ.

Chương này xây dựng bộ công cụ đa phương thức cho thị trường tài chính Việt Nam qua bốn kiến trúc có độ phức tạp tăng dần. Chúng ta bắt đầu với căn chỉnh biểu diễn (tức là cách ánh xạ các phương thức khác nhau vào một không gian nhúng chung). Sau đó chúng ta triển khai kết hợp sớm, kết hợp muộn và kết hợp chú ý chéo (cross-attention) cho dự báo lợi nhuận. Chúng ta xây dựng một hệ thống hiểu tài liệu đa phương thức xử lý đồng thời văn bản, bảng biểu và hình ảnh trong các báo cáo thường niên của Việt Nam. Chúng ta xây dựng một mô hình bất ngờ lợi nhuận đa phương thức kết hợp văn bản trước công bố, ảnh vệ tinh và chuỗi thời gian tài chính. Và chúng ta giải quyết các thách thức kỹ thuật thực tiễn, bao gồm phương thức bị thiếu, chi phí tính toán và các quy trình đánh giá quyết định liệu các mô hình đa phương thức có hoạt động được trong môi trường vận hành thực tế hay không.

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

# Deep learning
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import Dataset, DataLoader
import torchvision.transforms as transforms
import torchvision.models as models

# NLP
from transformers import (
    AutoTokenizer, AutoModel,
    CLIPProcessor, CLIPModel
)

# Tabular and statistical
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import TimeSeriesSplit
from sklearn.metrics import r2_score, mean_squared_error
from scipy import stats
import statsmodels.api as sm
from linearmodels.panel import PanelOLS

# Visualization
import plotnine as p9
from mizani.formatters import percent_format

48.1 Nền tảng của học đa phương thức

48.1.1 Cấu trúc thông tin của dữ liệu tài chính

Dữ liệu tài chính tự nhiên được tổ chức thành các phương thức với tính chất thống kê, tần suất thời gian và nội dung thông tin khác nhau. Bảng 48.1 tóm tắt các phương thức liên quan đến thị trường cổ phiếu Việt Nam.

Bảng 48.1: Bức tranh các phương thức trong dữ liệu tài chính
Phương thức Ví dụ Số chiều Tần suất Mã hóa
Dữ liệu bảng Các tỷ số tài chính, sở hữu, quản trị công ty Thấp (\(\sim\) 50 đặc trưng) Hằng quý/Hằng năm Số có cấu trúc
Văn bản Báo cáo thường niên, tin tức, hồ sơ công bố, mạng xã hội Cao (\(\sim\) 10k token) Theo sự kiện Chuỗi token tuần tự
Hình ảnh Ô ảnh vệ tinh, bản quét tài liệu, ảnh tin tức Rất cao (\(\sim\) 150k điểm ảnh) Hằng ngày đến hằng tháng Lưới không gian
Chuỗi thời gian Giá, khối lượng, dòng lệnh, biến động Trung bình (\(\sim\) 250 ngày × đặc trưng) Hằng ngày/Trong ngày Chuỗi thời gian
Âm thanh Cuộc họp công bố kết quả kinh doanh, bài thuyết trình hội nghị Rất cao (dạng sóng) Hằng quý Dạng sóng thời gian
Đồ thị Mạng lưới sở hữu, chuỗi cung ứng, cùng nắm giữ Thay đổi Hằng quý Ma trận kề + đặc trưng nút

Mỗi phương thức mang cả thông tin riêng biệt lẫn thông tin trùng lặp so với các phương thức khác. Giá trị của việc kết hợp đa phương thức nằm ở thông tin riêng biệt (bổ trợ):

\[ I(\text{Returns}; \text{Text}, \text{Image}, \text{Tabular}) \geq \max\left(I(\text{Returns}; \text{Text}), I(\text{Returns}; \text{Image}), I(\text{Returns}; \text{Tabular})\right) \tag{48.1}\]

trong đó \(I(\cdot; \cdot)\) ký hiệu thông tin tương hỗ. Bất đẳng thức là ngặt bất cứ khi nào các phương thức mang nội dung dự báo không trùng lặp. Mục tiêu của việc kết hợp là thiết kế các kiến trúc tiến gần tới vế trái.

48.1.2 Phân loại các cách kết hợp

Tài liệu học đa phương thức (Baltrušaitis, Ahuja, và Morency 2018; Liang, Zadeh, và Morency 2024) tổ chức các chiến lược kết hợp theo ba chiều.

Theo giai đoạn. Các phương thức được kết hợp ở đâu trong quy trình xử lý?

  • Kết hợp mức đầu vào (sớm): Nối các đặc trưng thô hoặc xử lý nhẹ trước bất kỳ mô hình chung nào.
  • Kết hợp mức đặc trưng (trung gian): Căn chỉnh các biểu diễn đã học trong một không gian tiềm ẩn chung, rồi kết hợp.
  • Kết hợp mức quyết định (muộn): Huấn luyện các mô hình riêng cho từng phương thức, rồi kết hợp các dự báo.

Theo cơ chế. Các biểu diễn được kết hợp như thế nào?

  • Nối (concatenation): \(\mathbf{z} = [\mathbf{z}^{(1)}; \mathbf{z}^{(2)}; \ldots; \mathbf{z}^{(M)}]\). Đơn giản nhưng bỏ qua các tương tác chéo giữa các phương thức.
  • Dựa trên chú ý (attention): Một phương thức chú ý đến một phương thức khác. Nắm bắt được các tương tác nhưng đòi hỏi đủ dữ liệu.
  • Tích tensor: \(\mathbf{z} = \mathbf{z}^{(1)} \otimes \mathbf{z}^{(2)}\). Nắm bắt mọi tương tác theo cặp nhưng tăng theo bình phương.
  • Cổng (gating): \(\mathbf{z} = g(\mathbf{z}^{(1)}) \odot \mathbf{z}^{(2)} + (1 - g(\mathbf{z}^{(1)})) \odot \mathbf{z}^{(3)}\). Lựa chọn phương thức.

Theo cách huấn luyện. Các tham số được học như thế nào?

  • Huấn luyện chung: Tất cả các phương thức được xử lý đầu-cuối (end-to-end).
  • Tiền huấn luyện rồi kết hợp: Huấn luyện riêng các bộ mã hóa đơn phương thức, sau đó học lớp kết hợp.
  • Căn chỉnh đối chiếu (contrastive): Huấn luyện các bộ mã hóa phương thức để tạo ra các biểu diễn tương tự nhau cho các cặp khớp nhau (cách tiếp cận CLIP của Radford và c.s. (2021)).
Hiện mã
# DataCore.vn API
from datacore import DataCore
dc = DataCore()

# Load aligned multimodal dataset
# Each observation: firm × quarter with all available modalities

# Tabular: financial statements
financials = dc.get_firm_financials(
    start_date="2014-01-01",
    end_date="2024-12-31",
    frequency="quarterly"
)

# Text: management discussion from annual reports
report_text = dc.get_annual_report_text(
    start_date="2014-01-01",
    end_date="2024-12-31",
    section="management_discussion"
)

# Image: satellite nightlight features (from Chapter 61)
satellite_features = dc.get_satellite_features(
    start_date="2014-01-01",
    end_date="2024-12-31",
    feature_type="cnn_resnet50"
)

# Time series: daily returns and volume
daily_data = dc.get_daily_returns(
    start_date="2014-01-01",
    end_date="2024-12-31"
)

# Target: forward quarterly returns
quarterly_returns = dc.get_quarterly_returns(
    start_date="2014-01-01",
    end_date="2024-12-31"
)

print(f"Firms with financials: {financials['ticker'].nunique()}")
print(f"Firms with report text: {report_text['ticker'].nunique()}")
print(f"Firms with satellite data: {satellite_features['ticker'].nunique()}")

48.2 Căn chỉnh biểu diễn

48.2.1 Bài toán căn chỉnh

Các phương thức khác nhau tạo ra các vector nhúng trong những không gian vector khác nhau với hình học khác nhau. Một vector nhúng văn bản PhoBERT nằm trong \(\mathbb{R}^{768}\); một đặc trưng ảnh ResNet50 nằm trong \(\mathbb{R}^{2048}\); một vector đặc trưng dữ liệu bảng có thể có 50 chiều với các thang đo không đồng nhất. Việc nối đơn giản chúng thành một vector duy nhất \([\mathbf{z}^{\text{text}}; \mathbf{z}^{\text{image}}; \mathbf{z}^{\text{tab}}] \in \mathbb{R}^{2866}\) gây ra vấn đề vì các phương thức nhiều chiều chi phối dòng gradient, các thang đo không khớp nhau, và không có cơ chế nào cho tương tác chéo giữa các phương thức.

Căn chỉnh chiếu từng phương thức vào một không gian tiềm ẩn chung \(\mathbb{R}^d\), nơi các quan hệ hình học có ý nghĩa ngữ nghĩa (tức là các doanh nghiệp tương tự nên ở gần nhau bất kể dùng phương thức nào để biểu diễn chúng).

48.2.2 Căn chỉnh đối chiếu: CLIP cho tài chính

Khung Contrastive Language-Image Pre-training (CLIP) của Radford và c.s. (2021) học các biểu diễn được căn chỉnh bằng cách huấn luyện trên các cặp (văn bản, hình ảnh) khớp nhau. Chúng ta điều chỉnh khung này cho dữ liệu tài chính: với mỗi doanh nghiệp-quý, chúng ta có một mô tả văn bản và một ảnh vệ tinh, và chúng ta huấn luyện các bộ mã hóa sao cho các cặp khớp nhau tạo ra vector nhúng tương tự còn các cặp không khớp tạo ra vector nhúng khác biệt.

Hàm mất mát đối chiếu là:

\[ \mathcal{L}_{\text{CLIP}} = -\frac{1}{2N}\sum_{i=1}^{N}\left[\log\frac{\exp(\mathbf{z}_i^{\text{txt}} \cdot \mathbf{z}_i^{\text{img}} / \tau)}{\sum_{j=1}^{N}\exp(\mathbf{z}_i^{\text{txt}} \cdot \mathbf{z}_j^{\text{img}} / \tau)} + \log\frac{\exp(\mathbf{z}_i^{\text{img}} \cdot \mathbf{z}_i^{\text{txt}} / \tau)}{\sum_{j=1}^{N}\exp(\mathbf{z}_i^{\text{img}} \cdot \mathbf{z}_j^{\text{txt}} / \tau)}\right] \tag{48.2}\]

trong đó \(\tau\) là tham số nhiệt độ có thể học và các vector nhúng được chuẩn hóa \(L_2\). Đây là phiên bản đối xứng của hàm mất mát InfoNCE (Oord, Li, và Vinyals 2018), đồng thời huấn luyện bộ mã hóa văn bản dự đoán đúng hình ảnh và ngược lại.

Hiện mã
class FinancialCLIP(nn.Module):
    """
    Contrastive alignment of text and image embeddings
    for Vietnamese financial data.
    """

    def __init__(self, text_dim=768, image_dim=2048, proj_dim=256):
        super().__init__()

        # Text projection
        self.text_proj = nn.Sequential(
            nn.Linear(text_dim, proj_dim),
            nn.LayerNorm(proj_dim),
            nn.GELU(),
            nn.Linear(proj_dim, proj_dim)
        )

        # Image projection
        self.image_proj = nn.Sequential(
            nn.Linear(image_dim, proj_dim),
            nn.LayerNorm(proj_dim),
            nn.GELU(),
            nn.Linear(proj_dim, proj_dim)
        )

        # Learnable temperature
        self.log_temp = nn.Parameter(torch.tensor(np.log(1 / 0.07)))

    def forward(self, text_emb, image_emb):
        """Compute aligned embeddings and contrastive loss."""
        # Project and normalize
        z_text = F.normalize(self.text_proj(text_emb), dim=-1)
        z_image = F.normalize(self.image_proj(image_emb), dim=-1)

        # Similarity matrix
        temp = self.log_temp.exp()
        logits = z_text @ z_image.T * temp

        # Symmetric cross-entropy loss
        labels = torch.arange(len(text_emb), device=text_emb.device)
        loss_t2i = F.cross_entropy(logits, labels)
        loss_i2t = F.cross_entropy(logits.T, labels)

        loss = (loss_t2i + loss_i2t) / 2

        return z_text, z_image, loss

    def encode_text(self, text_emb):
        return F.normalize(self.text_proj(text_emb), dim=-1)

    def encode_image(self, image_emb):
        return F.normalize(self.image_proj(image_emb), dim=-1)

48.2.3 Căn chỉnh chiếu cho các phương thức bất kỳ

Với nhiều hơn hai phương thức, chúng ta tổng quát hóa thành một không gian chiếu chung, trong đó mỗi phương thức có bộ mã hóa riêng nhưng tất cả các bộ mã hóa ánh xạ vào cùng một không gian đích:

\[ \mathbf{z}_i^{(m)} = f^{(m)}(\mathbf{x}_i^{(m)}; \boldsymbol{\theta}^{(m)}) \in \mathbb{R}^d, \qquad m = 1, \ldots, M \tag{48.3}\]

Hàm mất mát căn chỉnh khuyến khích mọi vector nhúng phương thức của cùng một quan sát phải tương tự nhau:

\[ \mathcal{L}_{\text{align}} = \sum_{m < m'} \frac{1}{N}\sum_{i=1}^{N} \left\|\mathbf{z}_i^{(m)} - \mathbf{z}_i^{(m')}\right\|^2 \tag{48.4}\]

Căn chỉnh MSE này đơn giản hơn căn chỉnh đối chiếu nhưng không bảo đảm tính chất phân biệt (các quan sát khác nhau nên có vector nhúng khác biệt). Trong thực tế, chúng ta kết hợp căn chỉnh với một mục tiêu dự báo:

\[ \mathcal{L}_{\text{total}} = \mathcal{L}_{\text{predict}}(\hat{y}, y) + \lambda \cdot \mathcal{L}_{\text{align}} \tag{48.5}\]

Hiện mã
class MultimodalProjector(nn.Module):
    """
    Project arbitrary modalities into a shared latent space.
    Supports variable numbers of modalities per observation.
    """

    def __init__(self, modality_dims, proj_dim=128, dropout=0.2):
        """
        Parameters
        ----------
        modality_dims : dict
            {modality_name: input_dim}, e.g.,
            {'text': 768, 'image': 2048, 'tabular': 50, 'ts': 128}
        proj_dim : int
            Shared projection dimensionality.
        """
        super().__init__()

        self.modality_names = list(modality_dims.keys())
        self.proj_dim = proj_dim

        # Per-modality encoders
        self.encoders = nn.ModuleDict()
        for name, dim in modality_dims.items():
            self.encoders[name] = nn.Sequential(
                nn.Linear(dim, proj_dim * 2),
                nn.LayerNorm(proj_dim * 2),
                nn.GELU(),
                nn.Dropout(dropout),
                nn.Linear(proj_dim * 2, proj_dim),
                nn.LayerNorm(proj_dim)
            )

    def forward(self, modality_inputs):
        """
        Parameters
        ----------
        modality_inputs : dict
            {modality_name: tensor}, may be missing some modalities.

        Returns
        -------
        dict : {modality_name: projected_embedding}
        """
        embeddings = {}
        for name, x in modality_inputs.items():
            if name in self.encoders and x is not None:
                embeddings[name] = self.encoders[name](x)

        return embeddings

    def compute_alignment_loss(self, embeddings):
        """Pairwise MSE alignment across all available modalities."""
        names = list(embeddings.keys())
        if len(names) < 2:
            return torch.tensor(0.0, device=next(self.parameters()).device)

        loss = torch.tensor(0.0, device=next(self.parameters()).device)
        n_pairs = 0
        for i in range(len(names)):
            for j in range(i + 1, len(names)):
                loss += F.mse_loss(
                    embeddings[names[i]], embeddings[names[j]]
                )
                n_pairs += 1

        return loss / n_pairs if n_pairs > 0 else loss

48.3 Kiến trúc kết hợp cho dự báo lợi nhuận

48.3.1 Bộ mã hóa đơn phương thức

Trước khi kết hợp các phương thức, chúng ta cần các bộ mã hóa tạo ra biểu diễn có số chiều cố định từ mỗi đầu vào thô. Chúng ta xây dựng bốn bộ mã hóa tương ứng với các phương thức chính trong thị trường cổ phiếu Việt Nam.

Hiện mã
class TabularEncoder(nn.Module):
    """Encode financial statement features."""

    def __init__(self, input_dim, hidden_dim=128, output_dim=64):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(input_dim, hidden_dim),
            nn.BatchNorm1d(hidden_dim),
            nn.ReLU(),
            nn.Dropout(0.3),
            nn.Linear(hidden_dim, hidden_dim),
            nn.BatchNorm1d(hidden_dim),
            nn.ReLU(),
            nn.Dropout(0.2),
            nn.Linear(hidden_dim, output_dim)
        )

    def forward(self, x):
        return self.net(x)


class TextEncoder(nn.Module):
    """
    Encode Vietnamese text using pre-extracted PhoBERT embeddings.
    Input: pre-computed [CLS] token embedding (768-d).
    """

    def __init__(self, input_dim=768, output_dim=64):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(input_dim, 256),
            nn.LayerNorm(256),
            nn.GELU(),
            nn.Dropout(0.2),
            nn.Linear(256, output_dim)
        )

    def forward(self, x):
        return self.net(x)


class ImageEncoder(nn.Module):
    """
    Encode satellite / document image features.
    Input: pre-computed CNN features (e.g., ResNet50 2048-d).
    """

    def __init__(self, input_dim=2048, output_dim=64):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(input_dim, 512),
            nn.LayerNorm(512),
            nn.GELU(),
            nn.Dropout(0.2),
            nn.Linear(512, output_dim)
        )

    def forward(self, x):
        return self.net(x)


class TimeSeriesEncoder(nn.Module):
    """
    Encode price/volume time series using a 1D CNN + attention.
    Input: (batch, seq_len, n_features) tensor of daily data.
    """

    def __init__(self, n_features=5, seq_len=60, output_dim=64):
        super().__init__()

        # 1D convolutional layers
        self.conv1 = nn.Conv1d(n_features, 32, kernel_size=5, padding=2)
        self.conv2 = nn.Conv1d(32, 64, kernel_size=3, padding=1)
        self.pool = nn.AdaptiveAvgPool1d(1)

        # Temporal attention
        self.attn = nn.MultiheadAttention(
            embed_dim=64, num_heads=4, batch_first=True
        )

        self.fc = nn.Linear(64, output_dim)

    def forward(self, x):
        # x: (B, T, F) -> (B, F, T) for Conv1d
        x = x.transpose(1, 2)
        x = F.relu(self.conv1(x))
        x = F.relu(self.conv2(x))

        # (B, 64, T) -> (B, T, 64) for attention
        x = x.transpose(1, 2)
        attn_out, _ = self.attn(x, x, x)

        # Pool over time
        x = attn_out.transpose(1, 2)  # (B, 64, T)
        x = self.pool(x).squeeze(-1)  # (B, 64)

        return self.fc(x)

48.3.2 Kết hợp sớm

Kết hợp sớm nối các vector nhúng phương thức trước một đầu dự báo chung. Đây là cách tiếp cận đơn giản nhất và đóng vai trò mô hình cơ sở tự nhiên.

Hiện mã
class EarlyFusionModel(nn.Module):
    """
    Concatenate modality embeddings, then predict.
    """

    def __init__(self, encoders, hidden_dim=128, output_dim=1):
        """
        Parameters
        ----------
        encoders : dict
            {modality_name: encoder_module}
            Each encoder outputs a vector of the same dimension.
        """
        super().__init__()
        self.encoders = nn.ModuleDict(encoders)
        self.n_modalities = len(encoders)

        # Infer encoder output dim from first encoder
        sample_encoder = list(encoders.values())[0]
        enc_dim = list(sample_encoder.parameters())[-1].shape[0]

        self.head = nn.Sequential(
            nn.Linear(enc_dim * self.n_modalities, hidden_dim),
            nn.LayerNorm(hidden_dim),
            nn.ReLU(),
            nn.Dropout(0.3),
            nn.Linear(hidden_dim, hidden_dim // 2),
            nn.ReLU(),
            nn.Linear(hidden_dim // 2, output_dim)
        )

    def forward(self, inputs):
        """
        Parameters
        ----------
        inputs : dict
            {modality_name: tensor}
        """
        embeddings = []
        for name, encoder in self.encoders.items():
            if name in inputs and inputs[name] is not None:
                embeddings.append(encoder(inputs[name]))
            else:
                # Zero-fill missing modalities
                device = next(self.parameters()).device
                enc_dim = list(encoder.parameters())[-1].shape[0]
                embeddings.append(torch.zeros(
                    inputs[list(inputs.keys())[0]].shape[0],
                    enc_dim, device=device
                ))

        combined = torch.cat(embeddings, dim=-1)
        return self.head(combined).squeeze(-1)

48.3.3 Kết hợp muộn

Kết hợp muộn huấn luyện các mô hình độc lập cho từng phương thức và kết hợp các dự báo của chúng. Các trọng số kết hợp có thể là cố định (lấy trung bình đều), được học (tuyến tính), hoặc thích ứng (mạng cổng).

Hiện mã
class LateFusionModel(nn.Module):
    """
    Independent prediction per modality, learned combination.
    """

    def __init__(self, encoders, enc_dim=64, combination="learned"):
        """
        Parameters
        ----------
        combination : str
            'average', 'learned', or 'gating'.
        """
        super().__init__()
        self.encoders = nn.ModuleDict(encoders)
        self.combination = combination
        self.n_modalities = len(encoders)

        # Per-modality prediction heads
        self.heads = nn.ModuleDict({
            name: nn.Linear(enc_dim, 1)
            for name in encoders
        })

        if combination == "learned":
            self.weights = nn.Parameter(
                torch.ones(self.n_modalities) / self.n_modalities
            )
        elif combination == "gating":
            # Gating network takes all embeddings as input
            self.gate = nn.Sequential(
                nn.Linear(enc_dim * self.n_modalities, self.n_modalities),
                nn.Softmax(dim=-1)
            )

    def forward(self, inputs):
        predictions = {}
        embeddings = {}

        for name, encoder in self.encoders.items():
            if name in inputs and inputs[name] is not None:
                emb = encoder(inputs[name])
                pred = self.heads[name](emb).squeeze(-1)
                predictions[name] = pred
                embeddings[name] = emb
            else:
                device = next(self.parameters()).device
                batch_size = inputs[list(inputs.keys())[0]].shape[0]
                predictions[name] = torch.zeros(batch_size, device=device)
                enc_dim = list(encoder.parameters())[-1].shape[0]
                embeddings[name] = torch.zeros(
                    batch_size, enc_dim, device=device
                )

        pred_stack = torch.stack(list(predictions.values()), dim=-1)

        if self.combination == "average":
            return pred_stack.mean(dim=-1)
        elif self.combination == "learned":
            weights = F.softmax(self.weights, dim=0)
            return (pred_stack * weights).sum(dim=-1)
        elif self.combination == "gating":
            all_emb = torch.cat(list(embeddings.values()), dim=-1)
            gate_weights = self.gate(all_emb)
            return (pred_stack * gate_weights).sum(dim=-1)

    def get_modality_weights(self):
        """Return the contribution of each modality."""
        if self.combination == "learned":
            return F.softmax(self.weights, dim=0).detach().cpu().numpy()
        return None

48.3.4 Kết hợp chú ý chéo

Kết hợp chú ý chéo là kiến trúc có khả năng biểu diễn mạnh nhất. Mỗi phương thức chú ý đến mọi phương thức khác, học xem những tương tác chéo nào mang nhiều thông tin. Đây là cơ chế nền tảng của các mô hình thị giác–ngôn ngữ hiện đại như Flamingo (Alayrac và c.s. 2022) và GPT-4V.

Phép chú ý chéo cho phương thức \(m\) chú ý đến phương thức \(m'\) là:

\[ \text{CA}^{(m \to m')} = \text{softmax}\left(\frac{\mathbf{Q}^{(m)} \left(\mathbf{K}^{(m')}\right)^\top}{\sqrt{d_k}}\right) \mathbf{V}^{(m')} \tag{48.6}\]

trong đó \(\mathbf{Q}^{(m)} = \mathbf{z}^{(m)} W_Q\), \(\mathbf{K}^{(m')} = \mathbf{z}^{(m')} W_K\), \(\mathbf{V}^{(m')} = \mathbf{z}^{(m')} W_V\). Đầu ra làm giàu biểu diễn của phương thức \(m\) bằng thông tin từ phương thức \(m'\).

Hiện mã
class CrossAttentionBlock(nn.Module):
    """Single cross-attention block: query modality attends to key modality."""

    def __init__(self, dim, n_heads=4, dropout=0.1):
        super().__init__()
        self.attn = nn.MultiheadAttention(
            embed_dim=dim, num_heads=n_heads,
            dropout=dropout, batch_first=True
        )
        self.norm1 = nn.LayerNorm(dim)
        self.norm2 = nn.LayerNorm(dim)
        self.ffn = nn.Sequential(
            nn.Linear(dim, dim * 4),
            nn.GELU(),
            nn.Dropout(dropout),
            nn.Linear(dim * 4, dim),
            nn.Dropout(dropout)
        )

    def forward(self, query, key_value):
        # Cross-attention
        q = query.unsqueeze(1) if query.dim() == 2 else query
        kv = key_value.unsqueeze(1) if key_value.dim() == 2 else key_value

        attn_out, attn_weights = self.attn(q, kv, kv)
        q = self.norm1(q + attn_out)

        # Feed-forward
        out = self.norm2(q + self.ffn(q))
        return out.squeeze(1) if query.dim() == 2 else out, attn_weights


class CrossAttentionFusionModel(nn.Module):
    """
    Full cross-attention fusion across M modalities.
    Each modality attends to all others via cross-attention blocks.
    """

    def __init__(self, encoders, enc_dim=64, n_layers=2, n_heads=4):
        super().__init__()
        self.encoders = nn.ModuleDict(encoders)
        self.modality_names = list(encoders.keys())
        self.n_modalities = len(encoders)

        # Cross-attention blocks: each modality attends to each other
        self.cross_attn_layers = nn.ModuleList()
        for _ in range(n_layers):
            layer = nn.ModuleDict()
            for m in self.modality_names:
                for m_prime in self.modality_names:
                    if m != m_prime:
                        layer[f"{m}_to_{m_prime}"] = CrossAttentionBlock(
                            enc_dim, n_heads
                        )
            self.cross_attn_layers.append(layer)

        # Prediction head
        self.head = nn.Sequential(
            nn.Linear(enc_dim * self.n_modalities, enc_dim),
            nn.LayerNorm(enc_dim),
            nn.ReLU(),
            nn.Dropout(0.2),
            nn.Linear(enc_dim, 1)
        )

    def forward(self, inputs):
        # Encode each modality
        embeddings = {}
        for name, encoder in self.encoders.items():
            if name in inputs and inputs[name] is not None:
                embeddings[name] = encoder(inputs[name])
            else:
                device = next(self.parameters()).device
                batch_size = inputs[list(inputs.keys())[0]].shape[0]
                enc_dim = list(encoder.parameters())[-1].shape[0]
                embeddings[name] = torch.zeros(
                    batch_size, enc_dim, device=device
                )

        # Cross-attention layers
        all_attn_weights = {}
        for layer in self.cross_attn_layers:
            new_embeddings = {k: v.clone() for k, v in embeddings.items()}
            for key, block in layer.items():
                parts = key.split("_to_")
                query_mod, kv_mod = parts[0], parts[1]
                if query_mod in embeddings and kv_mod in embeddings:
                    updated, weights = block(
                        embeddings[query_mod],
                        embeddings[kv_mod]
                    )
                    new_embeddings[query_mod] = (
                        new_embeddings[query_mod] + updated
                    )
                    all_attn_weights[key] = weights

            embeddings = new_embeddings

        # Concatenate and predict
        combined = torch.cat(
            [embeddings[name] for name in self.modality_names],
            dim=-1
        )
        return self.head(combined).squeeze(-1), all_attn_weights

48.3.5 Thí nghiệm so sánh

Bây giờ chúng ta so sánh ba kiến trúc kết hợp với các mô hình cơ sở đơn phương thức trong dự báo lợi nhuận hằng quý kỳ tới của cổ phiếu Việt Nam.

Hiện mã
class MultimodalFinanceDataset(Dataset):
    """
    Dataset that aligns multiple modalities per firm-quarter.
    Handles missing modalities with None values.
    """

    def __init__(self, tabular_df, text_embeddings, image_features,
                 ts_features, returns, tickers, dates):
        self.tabular = tabular_df
        self.text = text_embeddings
        self.image = image_features
        self.ts = ts_features
        self.returns = returns
        self.tickers = tickers
        self.dates = dates

    def __len__(self):
        return len(self.returns)

    def __getitem__(self, idx):
        sample = {
            "tabular": torch.tensor(
                self.tabular[idx], dtype=torch.float32
            ) if self.tabular[idx] is not None else None,
            "text": torch.tensor(
                self.text[idx], dtype=torch.float32
            ) if self.text[idx] is not None else None,
            "image": torch.tensor(
                self.image[idx], dtype=torch.float32
            ) if self.image[idx] is not None else None,
            "ts": torch.tensor(
                self.ts[idx], dtype=torch.float32
            ) if self.ts[idx] is not None else None,
            "return": torch.tensor(
                self.returns[idx], dtype=torch.float32
            ),
            "ticker": self.tickers[idx],
            "date": self.dates[idx]
        }
        return sample


def collate_multimodal(batch):
    """Custom collate that handles None modalities."""
    result = {"return": torch.stack([b["return"] for b in batch])}

    for mod in ["tabular", "text", "image", "ts"]:
        values = [b[mod] for b in batch]
        if all(v is not None for v in values):
            result[mod] = torch.stack(values)
        elif any(v is not None for v in values):
            # Fill None with zeros, matching shape of non-None entries
            ref = next(v for v in values if v is not None)
            filled = [v if v is not None else torch.zeros_like(ref)
                      for v in values]
            result[mod] = torch.stack(filled)
        else:
            result[mod] = None

    return result
Hiện mã
# Prepare aligned firm-quarter dataset
# Step 1: Financial ratios (tabular)
tabular_features = [
    "roe", "roa", "book_to_market", "log_size", "leverage",
    "asset_growth", "gross_profitability", "capex_to_assets",
    "cash_to_assets", "dividend_yield", "sales_growth",
    "accruals", "earnings_volatility", "beta"
]

financials["quarter_date"] = pd.to_datetime(
    financials["year"].astype(str) + "-" +
    (financials["quarter"] * 3).astype(str).str.zfill(2) + "-01"
)

# Step 2: Text embeddings from PhoBERT
# (Pre-computed in Chapter 60)
text_emb = dc.get_text_embeddings(
    model="phobert",
    section="management_discussion",
    start_date="2014-01-01",
    end_date="2024-12-31"
)

# Step 3: Image features (pre-computed in Chapter 61)
# Satellite CNN features linked to firm headquarters province

# Step 4: Time series features (60-day window before quarter end)
def compute_ts_features(ticker, date, daily_df, lookback=60):
    """Extract time-series feature tensor for a firm-quarter."""
    mask = (
        (daily_df["ticker"] == ticker) &
        (daily_df["date"] <= date) &
        (daily_df["date"] >= date - pd.Timedelta(days=lookback * 1.5))
    )
    subset = daily_df[mask].sort_values("date").tail(lookback)

    if len(subset) < lookback // 2:
        return None

    features = subset[["ret", "volume_log", "volatility_20d",
                        "spread", "turnover"]].values

    # Pad if shorter than lookback
    if len(features) < lookback:
        padding = np.zeros((lookback - len(features), features.shape[1]))
        features = np.vstack([padding, features])

    return features

# Step 5: Forward quarterly returns (target)
# Align everything to quarter-end dates
print("Preparing aligned multimodal dataset...")
Hiện mã
def train_multimodal_model(model, train_loader, val_loader,
                            n_epochs=50, lr=1e-3, patience=10,
                            alignment_weight=0.0):
    """
    Train a multimodal model with early stopping.

    Parameters
    ----------
    model : nn.Module
        Multimodal fusion model.
    alignment_weight : float
        Weight for modality alignment loss (0 = no alignment).

    Returns
    -------
    dict : Training history and best validation metrics.
    """
    optimizer = torch.optim.AdamW(model.parameters(), lr=lr,
                                   weight_decay=1e-4)
    scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(
        optimizer, mode="min", patience=5, factor=0.5
    )

    best_val_loss = float("inf")
    epochs_no_improve = 0
    history = {"train_loss": [], "val_loss": [], "val_r2": []}

    for epoch in range(n_epochs):
        # Training
        model.train()
        train_losses = []
        for batch in train_loader:
            optimizer.zero_grad()

            inputs = {k: batch[k] for k in ["tabular", "text", "image", "ts"]}
            targets = batch["return"]

            # Forward pass (handle both output types)
            output = model(inputs)
            if isinstance(output, tuple):
                predictions, attn_weights = output
            else:
                predictions = output

            loss = F.mse_loss(predictions, targets)

            # Optional alignment loss
            if alignment_weight > 0 and hasattr(model, "projector"):
                embeddings = model.projector(inputs)
                align_loss = model.projector.compute_alignment_loss(embeddings)
                loss = loss + alignment_weight * align_loss

            loss.backward()
            torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
            optimizer.step()

            train_losses.append(loss.item())

        # Validation
        model.eval()
        val_preds, val_targets = [], []
        val_losses = []

        with torch.no_grad():
            for batch in val_loader:
                inputs = {k: batch[k]
                          for k in ["tabular", "text", "image", "ts"]}
                targets = batch["return"]

                output = model(inputs)
                if isinstance(output, tuple):
                    predictions, _ = output
                else:
                    predictions = output

                val_losses.append(F.mse_loss(predictions, targets).item())
                val_preds.extend(predictions.cpu().numpy())
                val_targets.extend(targets.cpu().numpy())

        val_loss = np.mean(val_losses)
        val_r2 = r2_score(val_targets, val_preds) if len(val_preds) > 10 else 0

        history["train_loss"].append(np.mean(train_losses))
        history["val_loss"].append(val_loss)
        history["val_r2"].append(val_r2)

        scheduler.step(val_loss)

        # Early stopping
        if val_loss < best_val_loss:
            best_val_loss = val_loss
            best_state = {k: v.cpu().clone()
                         for k, v in model.state_dict().items()}
            epochs_no_improve = 0
        else:
            epochs_no_improve += 1
            if epochs_no_improve >= patience:
                break

    # Restore best model
    model.load_state_dict(best_state)

    return {
        "history": history,
        "best_val_loss": best_val_loss,
        "best_val_r2": max(history["val_r2"]),
        "epochs_trained": len(history["train_loss"])
    }
Hiện mã
def compare_fusion_strategies(dataset, n_splits=5):
    """
    Compare unimodal baselines and multimodal fusion strategies
    using expanding-window time-series cross-validation.

    Returns
    -------
    DataFrame : Out-of-sample R², MSE, IC for each model.
    """
    tscv = TimeSeriesSplit(n_splits=n_splits)
    results = []

    enc_dim = 64

    for fold, (train_idx, test_idx) in enumerate(
        tscv.split(range(len(dataset)))
    ):
        # Create data loaders
        train_subset = torch.utils.data.Subset(dataset, train_idx)
        test_subset = torch.utils.data.Subset(dataset, test_idx)

        train_loader = DataLoader(
            train_subset, batch_size=128, shuffle=True,
            collate_fn=collate_multimodal
        )
        test_loader = DataLoader(
            test_subset, batch_size=256, shuffle=False,
            collate_fn=collate_multimodal
        )

        # Define encoders
        def make_encoders():
            return {
                "tabular": TabularEncoder(len(tabular_features), 128, enc_dim),
                "text": TextEncoder(768, enc_dim),
                "image": ImageEncoder(2048, enc_dim),
                "ts": TimeSeriesEncoder(5, 60, enc_dim)
            }

        # Unimodal baselines
        for mod_name in ["tabular", "text", "image", "ts"]:
            single_encoder = {mod_name: make_encoders()[mod_name]}
            model = EarlyFusionModel(single_encoder, enc_dim, 1)

            result = train_multimodal_model(
                model, train_loader, test_loader, n_epochs=30
            )
            results.append({
                "fold": fold,
                "model": f"Unimodal ({mod_name})",
                "val_r2": result["best_val_r2"],
                "val_loss": result["best_val_loss"]
            })

        # Multimodal: Early Fusion
        model_early = EarlyFusionModel(make_encoders(), enc_dim * 2, 1)
        result = train_multimodal_model(
            model_early, train_loader, test_loader, n_epochs=30
        )
        results.append({
            "fold": fold,
            "model": "Early Fusion",
            "val_r2": result["best_val_r2"],
            "val_loss": result["best_val_loss"]
        })

        # Multimodal: Late Fusion (gating)
        model_late = LateFusionModel(
            make_encoders(), enc_dim, combination="gating"
        )
        result = train_multimodal_model(
            model_late, train_loader, test_loader, n_epochs=30
        )
        results.append({
            "fold": fold,
            "model": "Late Fusion (Gating)",
            "val_r2": result["best_val_r2"],
            "val_loss": result["best_val_loss"]
        })

        # Multimodal: Cross-Attention
        model_ca = CrossAttentionFusionModel(
            make_encoders(), enc_dim, n_layers=2, n_heads=4
        )
        result = train_multimodal_model(
            model_ca, train_loader, test_loader, n_epochs=30
        )
        results.append({
            "fold": fold,
            "model": "Cross-Attention Fusion",
            "val_r2": result["best_val_r2"],
            "val_loss": result["best_val_loss"]
        })

    return pd.DataFrame(results)
Bảng 48.2: Dự báo lợi nhuận ngoài mẫu: Đơn phương thức so với đa phương thức
Hiện mã
# results_df = compare_fusion_strategies(dataset)

# Aggregate across folds
# summary = (
#     results_df.groupby("model")
#     .agg(
#         mean_r2=("val_r2", "mean"),
#         std_r2=("val_r2", "std"),
#         mean_loss=("val_loss", "mean")
#     )
#     .sort_values("mean_r2", ascending=False)
#     .round(4)
# )
# summary
Hiện mã
# (
#     p9.ggplot(results_df, p9.aes(x="model", y="val_r2", fill="model"))
#     + p9.geom_boxplot(alpha=0.7)
#     + p9.coord_flip()
#     + p9.labs(
#         x="", y="Out-of-Sample R²",
#         title="Multimodal Fusion Improves Return Prediction"
#     )
#     + p9.theme_minimal()
#     + p9.theme(figure_size=(10, 6), legend_position="none")
# )
Hình 48.1

48.4 Xử lý các phương thức bị thiếu

48.4.1 Vấn đề phương thức bị thiếu

Trong thực tế, không phải doanh nghiệp-quý nào cũng có đủ mọi phương thức. Một doanh nghiệp có thể không có bản ghi chép cuộc họp công bố kết quả kinh doanh (không có âm thanh), trụ sở có thể nằm ở tỉnh mà vùng phủ vệ tinh không liên tục (không có hình ảnh), hoặc báo cáo thường niên có thể không được công bố công khai ở dạng số (không có văn bản). Điều này tạo ra vấn đề phương thức bị thiếu, khác về cấu trúc so với giá trị bị thiếu trong dữ liệu bảng: cả một vector đặc trưng (hàng trăm hay hàng nghìn chiều) bị mất.

Tỷ lệ quan sát có đủ cả bốn phương thức thường nhỏ hơn nhiều so với tỷ lệ có ít nhất một phương thức:

Bảng 48.3: Mức độ sẵn có của các phương thức trong dữ liệu thị trường Việt Nam
Các phương thức sẵn có Mức độ bao phủ điển hình (doanh nghiệp Việt Nam)
Chỉ dữ liệu bảng \(\sim\) 95% số doanh nghiệp-quý
Dữ liệu bảng + Văn bản \(\sim\) 70%
Dữ liệu bảng + Văn bản + Hình ảnh \(\sim\) 50%
Cả bốn (+ chuỗi thời gian) \(\sim\) 45%

Việc giới hạn mẫu vào các trường hợp đầy đủ loại bỏ một nửa dữ liệu và gây ra thiên lệch lựa chọn (các doanh nghiệp lớn hơn, minh bạch hơn được đại diện quá mức). Chúng ta cần các kiến trúc chỉ suy giảm từ từ, có kiểm soát khi thiếu phương thức.

48.4.2 Các chiến lược cho phương thức bị thiếu

  • Điền bằng không. Thay các vector nhúng phương thức bị thiếu bằng số không. Đơn giản nhưng gây thiên lệch: mô hình không thể phân biệt “phương thức này vắng mặt” với “phương thức này có tín hiệu bằng không.”

  • Vector nhúng mặc định được học. Thay các phương thức bị thiếu bằng một vector “mặc định” có thể học \(\mathbf{d}^{(m)}\) được huấn luyện cùng với mô hình. Điều này cho phép mô hình học xem sự vắng mặt của một phương thức hàm ý điều gì.

  • Loại bỏ phương thức (modality dropout). Trong huấn luyện, loại bỏ ngẫu nhiên toàn bộ các phương thức với xác suất \(p\) (tương tự dropout trên các nơ-ron). Điều này buộc mô hình hoạt động tốt ngay cả khi thiếu phương thức, và đóng vai trò chính quy hóa.

  • Hỗn hợp các chuyên gia (Mixture of Experts, MoE). Định tuyến mỗi quan sát đến một mạng con kết hợp chuyên biệt cho tổ hợp phương thức sẵn có của nó. Với \(M\) phương thức, có \(2^M - 1\) tập con khả dĩ, đòi hỏi chia sẻ tham số hiệu quả.

Hiện mã
class ModalityDropout(nn.Module):
    """
    Randomly drop entire modalities during training.
    Forces robustness to missing inputs at test time.
    """

    def __init__(self, drop_prob=0.2):
        super().__init__()
        self.drop_prob = drop_prob

    def forward(self, modality_inputs):
        if not self.training:
            return modality_inputs

        result = {}
        for name, tensor in modality_inputs.items():
            if tensor is not None and torch.rand(1).item() > self.drop_prob:
                result[name] = tensor
            else:
                result[name] = None

        # Ensure at least one modality remains
        if all(v is None for v in result.values()):
            # Keep the first available modality
            for name, tensor in modality_inputs.items():
                if tensor is not None:
                    result[name] = tensor
                    break

        return result


class RobustFusionModel(nn.Module):
    """
    Multimodal model robust to missing modalities.
    Uses learned default embeddings and modality dropout.
    """

    def __init__(self, encoders, enc_dim=64, drop_prob=0.2):
        super().__init__()
        self.encoders = nn.ModuleDict(encoders)
        self.modality_names = list(encoders.keys())
        self.n_modalities = len(encoders)
        self.enc_dim = enc_dim

        # Learned default embeddings for missing modalities
        self.defaults = nn.ParameterDict({
            name: nn.Parameter(torch.randn(enc_dim) * 0.01)
            for name in encoders
        })

        # Modality presence indicator embedding
        self.presence_proj = nn.Linear(self.n_modalities, enc_dim)

        # Modality dropout
        self.mod_dropout = ModalityDropout(drop_prob)

        # Attention-based aggregation
        self.attn_pool = nn.Sequential(
            nn.Linear(enc_dim, 1),
            nn.Softmax(dim=0)
        )

        # Prediction head
        self.head = nn.Sequential(
            nn.Linear(enc_dim * 2, enc_dim),
            nn.LayerNorm(enc_dim),
            nn.ReLU(),
            nn.Dropout(0.2),
            nn.Linear(enc_dim, 1)
        )

    def forward(self, inputs):
        # Apply modality dropout during training
        inputs = self.mod_dropout(inputs)

        embeddings = []
        presence = []

        for name in self.modality_names:
            if name in inputs and inputs[name] is not None:
                emb = self.encoders[name](inputs[name])
                embeddings.append(emb)
                presence.append(1.0)
            else:
                batch_size = next(
                    v.shape[0] for v in inputs.values()
                    if v is not None
                )
                emb = self.defaults[name].unsqueeze(0).expand(
                    batch_size, -1
                )
                embeddings.append(emb)
                presence.append(0.0)

        # Stack: (n_modalities, batch, enc_dim)
        emb_stack = torch.stack(embeddings, dim=0)

        # Attention-weighted aggregation
        attn_weights = self.attn_pool(emb_stack)  # (n_mod, batch, 1)
        aggregated = (emb_stack * attn_weights).sum(dim=0)  # (batch, enc_dim)

        # Presence indicator
        device = aggregated.device
        presence_tensor = torch.tensor(
            presence, device=device
        ).unsqueeze(0).expand(aggregated.shape[0], -1)
        presence_emb = self.presence_proj(presence_tensor)

        # Combine
        combined = torch.cat([aggregated, presence_emb], dim=-1)
        return self.head(combined).squeeze(-1)

48.5 Hiểu tài liệu đa phương thức

48.5.1 Báo cáo thường niên như một đối tượng đa phương thức

Một báo cáo thường niên của Việt Nam vốn dĩ là đa phương thức: nó chứa văn bản liên tục (thảo luận của ban lãnh đạo, các yếu tố rủi ro, chiến lược), bảng biểu (báo cáo tài chính, dữ liệu theo bộ phận, cơ cấu cổ đông), hình ảnh (ảnh cơ sở vật chất, sản phẩm, ban lãnh đạo) và biểu đồ (xu hướng doanh thu, thị phần). Các chương trước coi những thành phần này là các bài toán trích xuất riêng biệt. Ở đây chúng ta xây dựng một mô hình xử lý toàn bộ báo cáo như một tài liệu đa phương thức thống nhất.

Kiến trúc theo cách tiếp cận Document Understanding Transformer (Donut) của Kim và c.s. (2022), được điều chỉnh cho các hồ sơ công bố tài chính của Việt Nam:

\[ \mathbf{h} = \text{Encoder}(\mathbf{I}_{\text{page}}) + \text{Encoder}(\mathbf{T}_{\text{ocr}}) + \text{Encoder}(\mathbf{L}_{\text{layout}}) \tag{48.7}\]

trong đó \(\mathbf{I}\) là ảnh trang, \(\mathbf{T}\) là văn bản OCR, và \(\mathbf{L}\) là bố cục không gian (các khung bao). Biểu diễn chung \(\mathbf{h}\) nắm bắt cả những gì được viết lẫn vị trí xuất hiện của chúng trên trang.

Hiện mã
class MultimodalDocumentEncoder(nn.Module):
    """
    Joint encoder for Vietnamese annual report pages.
    Processes text, layout, and page image simultaneously.
    """

    def __init__(self, vocab_size=64000, max_boxes=512,
                 img_dim=2048, hidden_dim=256, n_layers=4,
                 n_heads=8):
        super().__init__()

        # Text embedding (Vietnamese tokens)
        self.text_emb = nn.Embedding(vocab_size, hidden_dim)

        # Layout embedding (bounding box coordinates)
        # Each box: [x0, y0, x1, y1] normalized to [0, 1000]
        self.x_emb = nn.Embedding(1001, hidden_dim // 4)
        self.y_emb = nn.Embedding(1001, hidden_dim // 4)

        # Image patch embedding
        self.img_proj = nn.Sequential(
            nn.Linear(img_dim, hidden_dim),
            nn.LayerNorm(hidden_dim)
        )

        # Modality type embedding
        self.modality_emb = nn.Embedding(3, hidden_dim)  # text, layout, image

        # Transformer encoder
        encoder_layer = nn.TransformerEncoderLayer(
            d_model=hidden_dim,
            nhead=n_heads,
            dim_feedforward=hidden_dim * 4,
            dropout=0.1,
            activation="gelu",
            batch_first=True
        )
        self.transformer = nn.TransformerEncoder(
            encoder_layer, num_layers=n_layers
        )

        # [CLS] token
        self.cls_token = nn.Parameter(torch.randn(1, 1, hidden_dim))

    def embed_layout(self, boxes):
        """Embed bounding box coordinates."""
        x0 = self.x_emb(boxes[:, :, 0])
        y0 = self.y_emb(boxes[:, :, 1])
        x1 = self.x_emb(boxes[:, :, 2])
        y1 = self.y_emb(boxes[:, :, 3])
        return torch.cat([x0, y0, x1, y1], dim=-1)

    def forward(self, token_ids, boxes, img_features,
                attention_mask=None):
        """
        Parameters
        ----------
        token_ids : LongTensor (B, T)
            OCR token IDs.
        boxes : LongTensor (B, T, 4)
            Bounding boxes for each token.
        img_features : Tensor (B, P, img_dim)
            Image patch features from CNN.
        """
        batch_size = token_ids.shape[0]

        # Text + layout
        text_h = self.text_emb(token_ids) + self.embed_layout(boxes)
        text_h = text_h + self.modality_emb(
            torch.zeros(batch_size, text_h.shape[1],
                       dtype=torch.long, device=text_h.device)
        )

        # Image patches
        img_h = self.img_proj(img_features)
        img_h = img_h + self.modality_emb(
            torch.full((batch_size, img_h.shape[1]), 2,
                      dtype=torch.long, device=img_h.device)
        )

        # Prepend [CLS]
        cls = self.cls_token.expand(batch_size, -1, -1)

        # Concatenate all modalities
        sequence = torch.cat([cls, text_h, img_h], dim=1)

        # Transformer encoding
        output = self.transformer(sequence)

        # Return [CLS] representation
        return output[:, 0, :]

48.5.2 Trích xuất các chỉ tiêu tài chính có cấu trúc từ các báo cáo đa phương thức

Với bộ mã hóa tài liệu, chúng ta có thể xây dựng các đầu trích xuất cho các chỉ tiêu tài chính cụ thể. Ưu điểm then chốt so với quy trình chỉ dùng OCR ở chương trước là bộ mã hóa đa phương thức có thể giải quyết các nhập nhằng bằng ngữ cảnh thị giác (chẳng hạn, ý nghĩa của một con số phụ thuộc vào vị trí xuất hiện của nó trên trang và các tiêu đề, nhãn xung quanh).

Hiện mã
class FinancialFieldExtractor(nn.Module):
    """
    Extract specific financial fields from a document embedding.
    Uses the multimodal document encoder as backbone.
    """

    def __init__(self, doc_encoder, fields, hidden_dim=256):
        """
        Parameters
        ----------
        doc_encoder : MultimodalDocumentEncoder
        fields : list
            Target field names, e.g.,
            ['revenue', 'net_income', 'total_assets', 'total_equity']
        """
        super().__init__()
        self.doc_encoder = doc_encoder
        self.fields = fields

        # Per-field extraction heads
        self.extractors = nn.ModuleDict({
            field: nn.Sequential(
                nn.Linear(hidden_dim, hidden_dim // 2),
                nn.GELU(),
                nn.Linear(hidden_dim // 2, 1)
            )
            for field in fields
        })

        # Confidence head
        self.confidence = nn.ModuleDict({
            field: nn.Sequential(
                nn.Linear(hidden_dim, 1),
                nn.Sigmoid()
            )
            for field in fields
        })

    def forward(self, token_ids, boxes, img_features):
        doc_emb = self.doc_encoder(token_ids, boxes, img_features)

        results = {}
        for field in self.fields:
            value = self.extractors[field](doc_emb).squeeze(-1)
            conf = self.confidence[field](doc_emb).squeeze(-1)
            results[field] = {"value": value, "confidence": conf}

        return results

48.6 Mô hình bất ngờ lợi nhuận đa phương thức

48.6.1 Kiến trúc

Bây giờ chúng ta xây dựng ứng dụng thực nghiệm trung tâm của chương: một mô hình đa phương thức dự báo bất ngờ lợi nhuận bằng tất cả các phương thức sẵn có được quan sát trước ngày công bố lợi nhuận.

Tập thông tin tại thời điểm \(t^-\) (ngay trước công bố) bao gồm:

  • Dữ liệu bảng: Các tỷ số tài chính được báo cáo gần nhất, dự báo đồng thuận của các nhà phân tích
  • Văn bản: Các bài báo và hồ sơ công bố trong cửa sổ trước công bố
  • Hình ảnh: Các đặc trưng vệ tinh của khu vực hoạt động của doanh nghiệp
  • Chuỗi thời gian: Động thái giá và khối lượng trong 60 ngày giao dịch trước công bố

Biến mục tiêu là lợi nhuận bất ngờ chuẩn hóa (SUE):

\[ \text{SUE}_{i,q} = \frac{E_{i,q} - \hat{E}_{i,q}}{\sigma_{i,q}} \tag{48.8}\]

trong đó \(E_{i,q}\) là lợi nhuận trên mỗi cổ phiếu thực tế, \(\hat{E}_{i,q}\) là dự báo đồng thuận (hoặc dự báo bước đi ngẫu nhiên theo mùa nếu không có phân tích viên theo dõi), và \(\sigma_{i,q}\) là độ lệch chuẩn của các sai số dự báo.

Hiện mã
# Construct earnings surprise dataset
earnings = dc.get_earnings_announcements(
    start_date="2016-01-01",
    end_date="2024-12-31"
)

# Standardized Unexpected Earnings
earnings["sue"] = (
    (earnings["actual_eps"] - earnings["consensus_eps"]) /
    earnings["forecast_std"].clip(lower=0.01)
)

# Pre-announcement features
# Text: aggregate PhoBERT sentiment of news in [-30, -1] window
pre_ann_text = dc.get_pre_announcement_text_features(
    start_date="2016-01-01",
    end_date="2024-12-31",
    window_days=30,
    model="phobert"
)

# Image: satellite features at quarter end
pre_ann_image = satellite_features.copy()

# Time series: 60 trading days before announcement
# (Pre-computed above)

# Tabular: most recent quarterly financials
pre_ann_tabular = financials[tabular_features + ["ticker", "quarter_date"]]

print(f"Earnings announcements: {len(earnings)}")
print(f"With text features: {len(pre_ann_text)}")
Hiện mã
class MultimodalEarningsSurpriseModel(nn.Module):
    """
    Predict standardized unexpected earnings (SUE) from
    multimodal pre-announcement information.
    """

    def __init__(self, tab_dim, text_dim=768, img_dim=2048,
                 ts_features=5, ts_len=60, hidden_dim=64,
                 n_heads=4, drop_prob=0.2):
        super().__init__()

        # Unimodal encoders
        self.tab_enc = TabularEncoder(tab_dim, 128, hidden_dim)
        self.text_enc = TextEncoder(text_dim, hidden_dim)
        self.img_enc = ImageEncoder(img_dim, hidden_dim)
        self.ts_enc = TimeSeriesEncoder(ts_features, ts_len, hidden_dim)

        # Modality dropout
        self.mod_dropout = ModalityDropout(drop_prob)

        # Cross-attention: text attends to time series
        # (news context informs price dynamics interpretation)
        self.text_ts_attn = CrossAttentionBlock(hidden_dim, n_heads)

        # Cross-attention: tabular attends to image
        # (financial ratios contextualized by physical activity)
        self.tab_img_attn = CrossAttentionBlock(hidden_dim, n_heads)

        # Modality importance weights (learned)
        self.importance = nn.Parameter(torch.ones(4))

        # Prediction head
        self.head = nn.Sequential(
            nn.Linear(hidden_dim * 2, hidden_dim),
            nn.LayerNorm(hidden_dim),
            nn.GELU(),
            nn.Dropout(0.3),
            nn.Linear(hidden_dim, hidden_dim // 2),
            nn.GELU(),
            nn.Linear(hidden_dim // 2, 1)
        )

    def forward(self, tabular, text, image, ts):
        # Encode each modality
        h_tab = self.tab_enc(tabular) if tabular is not None else None
        h_txt = self.text_enc(text) if text is not None else None
        h_img = self.img_enc(image) if image is not None else None
        h_ts = self.ts_enc(ts) if ts is not None else None

        # Cross-attention pairs (if both available)
        if h_txt is not None and h_ts is not None:
            h_txt_enriched, _ = self.text_ts_attn(h_txt, h_ts)
        else:
            h_txt_enriched = h_txt

        if h_tab is not None and h_img is not None:
            h_tab_enriched, _ = self.tab_img_attn(h_tab, h_img)
        else:
            h_tab_enriched = h_tab

        # Weighted combination of available modalities
        embeddings = []
        weights = F.softmax(self.importance, dim=0)

        for i, h in enumerate([h_tab_enriched, h_txt_enriched,
                                h_img, h_ts]):
            if h is not None:
                embeddings.append(h * weights[i])
            else:
                device = next(self.parameters()).device
                batch_size = next(
                    x.shape[0] for x in [tabular, text, image, ts]
                    if x is not None
                )
                embeddings.append(
                    torch.zeros(batch_size, h_tab.shape[-1]
                               if h_tab is not None else 64,
                               device=device)
                )

        # Aggregate
        stacked = torch.stack(embeddings, dim=0)
        aggregated = stacked.sum(dim=0)

        # Also compute variance across modalities (disagreement signal)
        if stacked.shape[0] > 1:
            disagreement = stacked.var(dim=0)
        else:
            disagreement = torch.zeros_like(aggregated)

        combined = torch.cat([aggregated, disagreement], dim=-1)
        return self.head(combined).squeeze(-1)

48.6.2 Phân tích tầm quan trọng của phương thức

Một câu hỏi then chốt về khả năng diễn giải là: phương thức nào đóng góp nhiều nhất vào dự báo bất ngờ lợi nhuận? Chúng ta phân tích các trọng số tầm quan trọng đã học và thực hiện các thí nghiệm loại bỏ (ablation).

Hiện mã
def ablation_study(model, test_loader, modality_names):
    """
    Measure each modality's contribution via leave-one-out ablation.

    For each modality m, zero out that modality's input and measure
    the degradation in prediction accuracy.

    Returns
    -------
    DataFrame : Modality, R² with all, R² without, Δ R².
    """
    model.eval()

    # Full model performance
    all_preds, all_targets = [], []
    with torch.no_grad():
        for batch in test_loader:
            inputs = {k: batch[k] for k in modality_names}
            targets = batch["return"]

            output = model(inputs)
            pred = output[0] if isinstance(output, tuple) else output
            all_preds.extend(pred.cpu().numpy())
            all_targets.extend(targets.cpu().numpy())

    r2_full = r2_score(all_targets, all_preds)

    # Ablation: remove one modality at a time
    results = [{"modality": "All", "r2": r2_full, "delta_r2": 0.0}]

    for drop_mod in modality_names:
        ablated_preds = []
        with torch.no_grad():
            for batch in test_loader:
                inputs = {}
                for k in modality_names:
                    if k == drop_mod:
                        inputs[k] = None  # Remove this modality
                    else:
                        inputs[k] = batch[k]

                targets = batch["return"]
                output = model(inputs)
                pred = output[0] if isinstance(output, tuple) else output
                ablated_preds.extend(pred.cpu().numpy())

        r2_ablated = r2_score(all_targets, ablated_preds)
        results.append({
            "modality": f"Without {drop_mod}",
            "r2": r2_ablated,
            "delta_r2": r2_full - r2_ablated
        })

    return pd.DataFrame(results)
Bảng 48.4: Nghiên cứu loại bỏ phương thức: Đóng góp vào dự báo bất ngờ lợi nhuận
Hiện mã
# ablation_df = ablation_study(model, test_loader, modality_names)
# ablation_df.round(4)
Hiện mã
# Track importance weights during training
# importance_history = pd.DataFrame(...)

# (
#     p9.ggplot(importance_history, p9.aes(
#         x="epoch", y="weight", color="modality"
#     ))
#     + p9.geom_line(size=1)
#     + p9.labs(
#         x="Training Epoch", y="Softmax Weight",
#         title="Modality Importance Convergence",
#         color="Modality"
#     )
#     + p9.scale_color_manual(
#         values=["#2E5090", "#C0392B", "#27AE60", "#8E44AD"]
#     )
#     + p9.theme_minimal()
#     + p9.theme(figure_size=(10, 5))
# )
Hình 48.2

48.7 Các mô hình đa phương thức lớn cho phân tích tài chính

48.7.1 Đưa câu lệnh (prompting) cho các mô hình thị giác–ngôn ngữ

Các hệ thống đa phương thức mạnh nhất hiện có là các mô hình thị giác–ngôn ngữ lớn (VLM) như GPT-4V, Gemini và các lựa chọn mã nguồn mở (LLaVA, InternVL). Các mô hình này có thể xử lý đồng thời hình ảnh và văn bản thông qua các câu lệnh ngôn ngữ tự nhiên, cho phép phân tích tài chính zero-shot mà không cần huấn luyện mô hình.

Đối với các ứng dụng tài chính ở Việt Nam, VLM có thể:

  • Diễn giải ảnh vệ tinh các khu công nghiệp và ước tính mức độ hoạt động
  • Đọc và trích xuất dữ liệu từ các bảng tài chính được quét
  • Phân tích ảnh tin tức để xác định tâm lý
  • So sánh các góc nhìn từ trên không hiện tại và lịch sử để phát hiện thay đổi
Hiện mã
def vlm_financial_qa(image_path, question, context=None):
    """
    Financial question-answering using a vision-language model.

    Parameters
    ----------
    image_path : str
        Path to image (satellite tile, document page, news photo).
    question : str
        Financial analysis question.
    context : str, optional
        Additional textual context (e.g., firm name, sector).

    Returns
    -------
    dict : Answer, confidence, extracted entities.
    """
    from transformers import (
        LlavaForConditionalGeneration,
        LlavaProcessor
    )

    model_id = "llava-hf/llava-v1.6-vicuna-7b-hf"
    processor = LlavaProcessor.from_pretrained(model_id)
    model = LlavaForConditionalGeneration.from_pretrained(
        model_id, torch_dtype=torch.float16, device_map="auto"
    )

    img = Image.open(image_path).convert("RGB")

    # Build financial analysis prompt
    system_prompt = (
        "You are a financial analyst examining visual evidence. "
        "Provide specific, quantitative observations when possible. "
        "State your confidence level (high/medium/low)."
    )

    if context:
        prompt = (
            f"{system_prompt}\n\nContext: {context}\n\n"
            f"Question: {question}\n\nAnswer:"
        )
    else:
        prompt = f"{system_prompt}\n\nQuestion: {question}\n\nAnswer:"

    inputs = processor(
        text=prompt,
        images=img,
        return_tensors="pt"
    ).to(model.device)

    with torch.no_grad():
        output = model.generate(
            **inputs,
            max_new_tokens=300,
            temperature=0.1,
            do_sample=False
        )

    answer = processor.decode(output[0], skip_special_tokens=True)
    answer = answer.split("Answer:")[-1].strip()

    return {"answer": answer, "question": question}


# Example financial VLM queries
FINANCIAL_VLM_PROMPTS = {
    "satellite_activity": (
        "Examine this satellite image of an industrial zone. "
        "Estimate the occupancy rate of factory buildings, "
        "the density of vehicles in parking areas, "
        "and whether the site appears to be operating at "
        "full, partial, or minimal capacity."
    ),
    "document_extraction": (
        "This is a page from a Vietnamese annual report. "
        "Extract the following if present: "
        "total revenue (doanh thu), net income (lợi nhuận ròng), "
        "total assets (tổng tài sản). "
        "Report values in billions VND."
    ),
    "construction_progress": (
        "Compare this aerial image to a baseline. "
        "Estimate the percentage completion of visible "
        "construction projects. Note any new structures, "
        "cleared land, or infrastructure changes."
    )
}

48.7.2 Phân tích đa phương thức tăng cường truy xuất

Với các câu hỏi tài chính phức tạp, chúng ta có thể kết hợp năng lực của VLM với việc truy xuất từ các cơ sở dữ liệu có cấu trúc. Quy trình:

  1. Truy vấn: Nhà phân tích hỏi “Hoạt động xây dựng của Vingroup tại Vinhomes Grand Park có đang tăng tốc không?”
  2. Truy xuất: Lấy chuỗi thời gian vệ tinh, báo cáo tài chính, các bài báo tin tức
  3. Xử lý: VLM phân tích ảnh vệ tinh; NLP xử lý văn bản; mô hình dữ liệu bảng xử lý các chỉ tiêu tài chính
  4. Kết hợp: Tổng hợp bằng chứng từ các phương thức
  5. Trả lời: Tạo một câu trả lời có cấu trúc kèm điểm tin cậy và bằng chứng hỗ trợ
Hiện mã
class MultimodalRAG:
    """
    Retrieval-Augmented Generation with multimodal evidence.
    """

    def __init__(self, datacore_client, vlm_model=None):
        self.dc = datacore_client
        self.vlm = vlm_model

    def retrieve_evidence(self, ticker, date, modalities=None):
        """
        Retrieve all available evidence for a firm at a given date.
        """
        evidence = {}

        if modalities is None or "tabular" in modalities:
            evidence["tabular"] = self.dc.get_firm_financials(
                ticker=ticker,
                end_date=date,
                n_quarters=4
            )

        if modalities is None or "text" in modalities:
            evidence["text"] = self.dc.get_news(
                ticker=ticker,
                start_date=pd.to_datetime(date) - pd.Timedelta(days=30),
                end_date=date,
                limit=20
            )

        if modalities is None or "image" in modalities:
            evidence["image"] = self.dc.get_satellite_images(
                ticker=ticker,
                date=date,
                lookback_months=6
            )

        if modalities is None or "ts" in modalities:
            evidence["ts"] = self.dc.get_daily_returns(
                ticker=ticker,
                start_date=pd.to_datetime(date) - pd.Timedelta(days=90),
                end_date=date
            )

        return evidence

    def analyze(self, ticker, date, question):
        """
        Full multimodal analysis pipeline.
        """
        evidence = self.retrieve_evidence(ticker, date)

        analysis = {
            "ticker": ticker,
            "date": date,
            "question": question,
            "evidence_available": list(evidence.keys()),
            "modality_signals": {}
        }

        # Tabular signal
        if "tabular" in evidence and evidence["tabular"] is not None:
            latest = evidence["tabular"].iloc[-1]
            analysis["modality_signals"]["tabular"] = {
                "revenue_growth": latest.get("revenue_growth", None),
                "roe": latest.get("roe", None),
                "leverage": latest.get("leverage", None)
            }

        # Text signal
        if "text" in evidence and evidence["text"] is not None:
            # Aggregate sentiment from PhoBERT
            texts = evidence["text"]
            if len(texts) > 0:
                avg_sentiment = texts["sentiment_score"].mean()
                analysis["modality_signals"]["text"] = {
                    "avg_sentiment": avg_sentiment,
                    "n_articles": len(texts),
                    "sentiment_trend": (
                        "improving" if texts["sentiment_score"].is_monotonic_increasing
                        else "deteriorating" if texts["sentiment_score"].is_monotonic_decreasing
                        else "mixed"
                    )
                }

        # Time series signal
        if "ts" in evidence and evidence["ts"] is not None:
            ts = evidence["ts"]
            analysis["modality_signals"]["ts"] = {
                "return_60d": (1 + ts["ret"]).prod() - 1,
                "volatility": ts["ret"].std() * np.sqrt(252),
                "avg_turnover": ts["turnover"].mean()
            }

        return analysis

48.8 Các lưu ý về đánh giá và triển khai

48.8.1 Quy trình đánh giá cho các mô hình tài chính đa phương thức

Cách đánh giá học máy tiêu chuẩn (chia ngẫu nhiên tập huấn luyện/kiểm tra) không phù hợp cho dự báo tài chính. Chúng ta cần cách đánh giá có xét đến chuỗi thời gian, tôn trọng thứ tự thời gian của thông tin.

Bảng 48.5: Thực hành tốt nhất trong đánh giá các mô hình tài chính đa phương thức
Khía cạnh đánh giá Cách tiếp cận đúng Sai lầm thường gặp
Chia huấn luyện/kiểm tra Cửa sổ thời gian mở rộng hoặc trượt Chia ngẫu nhiên (thiên lệch nhìn trước)
Thời điểm của đặc trưng Các đặc trưng có sẵn trước ngày dự báo Dùng thông tin đồng thời hoặc tương lai
Phương thức bị thiếu Kiểm tra với các mẫu hình thiếu dữ liệu thực tế Chỉ dùng các trường hợp đầy đủ
Thước đo hiệu suất \(R^2\) ngoài mẫu (OOS), IC, Sharpe của danh mục L-S \(R^2\) trong mẫu
Suy luận thống kê Kiểm định Diebold và Mariano (2002) để so sánh dự báo Ước lượng điểm không có SE
Ý nghĩa kinh tế Lợi nhuận danh mục đã điều chỉnh chi phí giao dịch Bỏ qua chi phí triển khai

48.8.2 Ngân sách tính toán

Các mô hình đa phương thức tốn nhiều tài nguyên tính toán. Bảng 48.6 cung cấp các ước tính về bậc độ lớn cho thị trường cổ phiếu Việt Nam.

Bảng 48.6: Ngân sách tính toán cho quy trình đa phương thức
Thành phần Một doanh nghiệp-quý Toàn bộ bảng dữ liệu (1000 doanh nghiệp × 40 quý)
Mã hóa văn bản PhoBERT 0.5s ~5.5 giờ
Đặc trưng vệ tinh ResNet50 0.1s ~1.1 giờ
Mã hóa chuỗi thời gian (CNN) 0.01s ~7 phút
Tiền xử lý dữ liệu bảng <0.01s ~1 phút
Kết hợp chú ý chéo (lượt xuôi) 0.05s ~33 phút
Huấn luyện (50 epoch) – ~12 giờ (GPU)
Toàn bộ quy trình – ~1 ngày (một GPU)

Hệ quả thực tiễn là việc tính trước các vector nhúng đơn phương thức là thiết yếu. Hãy trích xuất và lưu đệm các vector nhúng PhoBERT, đặc trưng CNN và biểu diễn chuỗi thời gian một lần; dùng lại chúng trong mọi thí nghiệm kết hợp. Chỉ các lớp kết hợp cần huấn luyện lại khi kiến trúc thay đổi.

48.9 Tóm tắt

Chương này đã phát triển khung học đa phương thức cho thị trường tài chính Việt Nam, đi từ căn chỉnh biểu diễn nền tảng đến các kiến trúc kết hợp sẵn sàng vận hành.

Các đóng góp chính gồm ba điểm. Thứ nhất, chúng ta đã chứng minh rằng dữ liệu tài chính vốn dĩ là đa phương thức và việc kết hợp hiệu quả đòi hỏi những lựa chọn kiến trúc tường minh (chẳng hạn, căn chỉnh đối chiếu, cơ chế chú ý chéo và xử lý phương thức bị thiếu) thay vì nối đơn giản. Khung căn chỉnh FinancialCLIP học một không gian nhúng chung trong đó các biểu diễn văn bản, hình ảnh, dữ liệu bảng và chuỗi thời gian có thể so sánh được về mặt hình học, cho phép truy xuất chéo giữa các phương thức và chuyển giao.

Thứ hai, chúng ta đã xây dựng và so sánh năm kiến trúc kết hợp (sớm, muộn có cổng, chú ý chéo, vững với loại bỏ phương thức, và mô hình bất ngờ lợi nhuận tùy chỉnh) trong dự báo lợi nhuận kỳ tới và bất ngờ lợi nhuận. Kiến trúc chú ý chéo với loại bỏ phương thức luôn vượt trội so với các mô hình cơ sở đơn phương thức và các chiến lược kết hợp đơn giản hơn, dù mức chênh lệch thay đổi theo các kỳ dự báo và đặc điểm doanh nghiệp.

Thứ ba, chúng ta đã cho thấy các mô hình thị giác–ngôn ngữ lớn có thể thực hiện phân tích tài chính zero-shot trên các tài liệu và ảnh vệ tinh của Việt Nam, mở ra một con đường đến phân tích đa phương thức mà không cần huấn luyện riêng cho từng tác vụ. Quy trình đa phương thức tăng cường truy xuất kết hợp thế mạnh của truy xuất có cấu trúc (từ DataCore.vn) với năng lực suy luận của VLM.

Bài học thực tiễn cho các nhà nghiên cứu làm việc với dữ liệu tài chính Việt Nam là việc kết hợp đa phương thức có giá trị nhất khi các phương thức bổ trợ cho nhau: văn bản nắm bắt ý định của ban lãnh đạo và câu chuyện thị trường, hình ảnh nắm bắt hoạt động kinh tế vật lý, dữ liệu bảng cung cấp những ảnh chụp định lượng chính xác, và chuỗi thời gian nắm bắt động thái thị trường. Khi một phương thức đơn lẻ đã nắm bắt phần lớn tín hiệu liên quan (như các đặc trưng dữ liệu bảng làm được trong nhiều tác vụ dự báo tiêu chuẩn), lợi ích cận biên của việc kết hợp là khiêm tốn. Khi tác vụ dự báo đòi hỏi thông tin mà không phương thức đơn lẻ nào nắm bắt tốt (như bất ngờ lợi nhuận đòi hỏi cả đánh giá định lượng lẫn định tính), các mô hình đa phương thức mang lại lợi thế lớn nhất.