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 pdimport numpy as npfrom pathlib import Pathimport warningswarnings.filterwarnings("ignore")# Deep learningimport torchimport torch.nn as nnimport torch.nn.functional as Ffrom torch.utils.data import Dataset, DataLoaderimport torchvision.transforms as transformsimport torchvision.models as models# NLPfrom transformers import ( AutoTokenizer, AutoModel, CLIPProcessor, CLIPModel)# Tabular and statisticalfrom sklearn.preprocessing import StandardScalerfrom sklearn.model_selection import TimeSeriesSplitfrom sklearn.metrics import r2_score, mean_squared_errorfrom scipy import statsimport statsmodels.api as smfrom linearmodels.panel import PanelOLS# Visualizationimport plotnine as p9from 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ợ):
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.
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.
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 APIfrom datacore import DataCoredc = DataCore()# Load aligned multimodal dataset# Each observation: firm × quarter with all available modalities# Tabular: financial statementsfinancials = dc.get_firm_financials( start_date="2014-01-01", end_date="2024-12-31", frequency="quarterly")# Text: management discussion from annual reportsreport_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 volumedaily_data = dc.get_daily_returns( start_date="2014-01-01", end_date="2024-12-31")# Target: forward quarterly returnsquarterly_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.
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.
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:
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:
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 encodersself.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 inself.encoders and x isnotNone: embeddings[name] =self.encoders[name](x)return embeddingsdef compute_alignment_loss(self, embeddings):"""Pairwise MSE alignment across all available modalities.""" names =list(embeddings.keys())iflen(names) <2:return torch.tensor(0.0, device=next(self.parameters()).device) loss = torch.tensor(0.0, device=next(self.parameters()).device) n_pairs =0for i inrange(len(names)):for j inrange(i +1, len(names)): loss += F.mse_loss( embeddings[names[i]], embeddings[names[j]] ) n_pairs +=1return loss / n_pairs if n_pairs >0else 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):returnself.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):returnself.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):returnself.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 layersself.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 attentionself.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)returnself.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 inself.encoders.items():if name in inputs and inputs[name] isnotNone: 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)returnself.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 = combinationself.n_modalities =len(encoders)# Per-modality prediction headsself.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 inputself.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 inself.encoders.items():if name in inputs and inputs[name] isnotNone: emb = encoder(inputs[name]) pred =self.heads[name](emb).squeeze(-1) predictions[name] = pred embeddings[name] = embelse: 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)ifself.combination =="average":return pred_stack.mean(dim=-1)elifself.combination =="learned": weights = F.softmax(self.weights, dim=0)return (pred_stack * weights).sum(dim=-1)elifself.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."""ifself.combination =="learned":return F.softmax(self.weights, dim=0).detach().cpu().numpy()returnNone
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à:
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() ==2else query kv = key_value.unsqueeze(1) if key_value.dim() ==2else 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() ==2else out, attn_weightsclass 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 otherself.cross_attn_layers = nn.ModuleList()for _ inrange(n_layers): layer = nn.ModuleDict()for m inself.modality_names:for m_prime inself.modality_names:if m != m_prime: layer[f"{m}_to_{m_prime}"] = CrossAttentionBlock( enc_dim, n_heads )self.cross_attn_layers.append(layer)# Prediction headself.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 inself.encoders.items():if name in inputs and inputs[name] isnotNone: 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 inself.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 inself.modality_names], dim=-1 )returnself.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_dfself.text = text_embeddingsself.image = image_featuresself.ts = ts_featuresself.returns = returnsself.tickers = tickersself.dates = datesdef__len__(self):returnlen(self.returns)def__getitem__(self, idx): sample = {"tabular": torch.tensor(self.tabular[idx], dtype=torch.float32 ) ifself.tabular[idx] isnotNoneelseNone,"text": torch.tensor(self.text[idx], dtype=torch.float32 ) ifself.text[idx] isnotNoneelseNone,"image": torch.tensor(self.image[idx], dtype=torch.float32 ) ifself.image[idx] isnotNoneelseNone,"ts": torch.tensor(self.ts[idx], dtype=torch.float32 ) ifself.ts[idx] isnotNoneelseNone,"return": torch.tensor(self.returns[idx], dtype=torch.float32 ),"ticker": self.tickers[idx],"date": self.dates[idx] }return sampledef 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]ifall(v isnotNonefor v in values): result[mod] = torch.stack(values)elifany(v isnotNonefor v in values):# Fill None with zeros, matching shape of non-None entries ref =next(v for v in values if v isnotNone) filled = [v if v isnotNoneelse torch.zeros_like(ref)for v in values] result[mod] = torch.stack(filled)else: result[mod] =Nonereturn 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)iflen(subset) < lookback //2:returnNone features = subset[["ret", "volume_log", "volatility_20d","spread", "turnover"]].values# Pad if shorter than lookbackiflen(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 datesprint("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 inrange(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)ifisinstance(output, tuple): predictions, attn_weights = outputelse: predictions = output loss = F.mse_loss(predictions, targets)# Optional alignment lossif alignment_weight >0andhasattr(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)ifisinstance(output, tuple): predictions, _ = outputelse: 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) iflen(val_preds) >10else0 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 stoppingif 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 =0else: epochs_no_improve +=1if 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"]) }
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_probdef forward(self, modality_inputs):ifnotself.training:return modality_inputs result = {}for name, tensor in modality_inputs.items():if tensor isnotNoneand torch.rand(1).item() >self.drop_prob: result[name] = tensorelse: result[name] =None# Ensure at least one modality remainsifall(v isNonefor v in result.values()):# Keep the first available modalityfor name, tensor in modality_inputs.items():if tensor isnotNone: result[name] = tensorbreakreturn resultclass 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 modalitiesself.defaults = nn.ParameterDict({ name: nn.Parameter(torch.randn(enc_dim) *0.01)for name in encoders })# Modality presence indicator embeddingself.presence_proj = nn.Linear(self.n_modalities, enc_dim)# Modality dropoutself.mod_dropout = ModalityDropout(drop_prob)# Attention-based aggregationself.attn_pool = nn.Sequential( nn.Linear(enc_dim, 1), nn.Softmax(dim=0) )# Prediction headself.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 inself.modality_names:if name in inputs and inputs[name] isnotNone: 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 isnotNone ) 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)returnself.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:
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.
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_encoderself.fields = fields# Per-field extraction headsself.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 headself.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 inself.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):
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 datasetearnings = dc.get_earnings_announcements( start_date="2016-01-01", end_date="2024-12-31")# Standardized Unexpected Earningsearnings["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] windowpre_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 endpre_ann_image = satellite_features.copy()# Time series: 60 trading days before announcement# (Pre-computed above)# Tabular: most recent quarterly financialspre_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 encodersself.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 dropoutself.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 headself.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 isnotNoneelseNone h_txt =self.text_enc(text) if text isnotNoneelseNone h_img =self.img_enc(image) if image isnotNoneelseNone h_ts =self.ts_enc(ts) if ts isnotNoneelseNone# Cross-attention pairs (if both available)if h_txt isnotNoneand h_ts isnotNone: h_txt_enriched, _ =self.text_ts_attn(h_txt, h_ts)else: h_txt_enriched = h_txtif h_tab isnotNoneand h_img isnotNone: 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 inenumerate([h_tab_enriched, h_txt_enriched, h_img, h_ts]):if h isnotNone: 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 isnotNone ) embeddings.append( torch.zeros(batch_size, h_tab.shape[-1]if h_tab isnotNoneelse64, 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)returnself.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] ifisinstance(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 modalityelse: inputs[k] = batch[k] targets = batch["return"] output = model(inputs) pred = output[0] ifisinstance(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
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 queriesFINANCIAL_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:
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?”
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
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
Kết hợp: Tổng hợp bằng chứng từ các phương thức
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_clientself.vlm = vlm_modeldef retrieve_evidence(self, ticker, date, modalities=None):""" Retrieve all available evidence for a firm at a given date. """ evidence = {}if modalities isNoneor"tabular"in modalities: evidence["tabular"] =self.dc.get_firm_financials( ticker=ticker, end_date=date, n_quarters=4 )if modalities isNoneor"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 isNoneor"image"in modalities: evidence["image"] =self.dc.get_satellite_images( ticker=ticker, date=date, lookback_months=6 )if modalities isNoneor"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 evidencedef 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 signalif"tabular"in evidence and evidence["tabular"] isnotNone: 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 signalif"text"in evidence and evidence["text"] isnotNone:# Aggregate sentiment from PhoBERT texts = evidence["text"]iflen(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_increasingelse"deteriorating"if texts["sentiment_score"].is_monotonic_decreasingelse"mixed" ) }# Time series signalif"ts"in evidence and evidence["ts"] isnotNone: 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.
Alayrac, Jean-Baptiste, Jeff Donahue, Pauline Luc, Antoine Miech, Iain Barr, Yana Hasson, Karel Lenc, và c.s. 2022. “Flamingo: a visual language model for few-shot learning”. Advances in neural information processing systems 35: 23716–36.
Baltrušaitis, Tadas, Chaitanya Ahuja, và Louis-Philippe Morency. 2018. “Multimodal machine learning: A survey and taxonomy”. IEEE transactions on pattern analysis and machine intelligence 41 (2): 423–43.
Diebold, Francis X, và Robert S Mariano. 2002. “Comparing predictive accuracy”. Journal of Business & economic statistics 20 (1): 134–44.
Huang, Yu, Chenzhuang Du, Zihui Xue, Xuanyao Chen, Hang Zhao, và Longbo Huang. 2021. “What makes multi-modal learning better than single (provably)”. Advances in Neural Information Processing Systems 34: 10944–56.
Kim, Geewook, Teakgyu Hong, Moonbin Yim, JeongYeon Nam, Jinyoung Park, Jinyeong Yim, Wonseok Hwang, Sangdoo Yun, Dongyoon Han, và Seunghyun Park. 2022. “Ocr-free document understanding transformer”. Trong European Conference on Computer Vision, 498–517. Springer.
Liang, Paul Pu, Amir Zadeh, và Louis-Philippe Morency. 2024. “Foundations & trends in multimodal machine learning: Principles, challenges, and open questions”. ACM computing surveys 56 (10): 1–42.
Oord, Aaron van den, Yazhe Li, và Oriol Vinyals. 2018. “Representation learning with contrastive predictive coding”. arXiv preprint arXiv:1807.03748.
Radford, Alec, Jong Wook Kim, Chris Hallacy, Aditya Ramesh, Gabriel Goh, Sandhini Agarwal, Girish Sastry, và c.s. 2021. “Learning transferable visual models from natural language supervision”. Trong International conference on machine learning, 139:8748–63. Proceedings of Machine Learning Research. PMLR.
# Các mô hình đa phương thức trong tài chínhCá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) [@huang2021makes]. 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.```{python}#| label: setup#| message: falseimport pandas as pdimport numpy as npfrom pathlib import Pathimport warningswarnings.filterwarnings("ignore")# Deep learningimport torchimport torch.nn as nnimport torch.nn.functional as Ffrom torch.utils.data import Dataset, DataLoaderimport torchvision.transforms as transformsimport torchvision.models as models# NLPfrom transformers import ( AutoTokenizer, AutoModel, CLIPProcessor, CLIPModel)# Tabular and statisticalfrom sklearn.preprocessing import StandardScalerfrom sklearn.model_selection import TimeSeriesSplitfrom sklearn.metrics import r2_score, mean_squared_errorfrom scipy import statsimport statsmodels.api as smfrom linearmodels.panel import PanelOLS# Visualizationimport plotnine as p9from mizani.formatters import percent_format```## Nền tảng của học đa phương thức### Cấu trúc thông tin của dữ liệu tài chínhDữ 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. @tbl-modality-landscape tóm tắt các phương thức liên quan đến thị trường cổ phiếu Việt Nam.| 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 |: Bức tranh các phương thức trong dữ liệu tài chính {#tbl-modality-landscape}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)$$ {#eq-information-inequality}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.### Phân loại các cách kết hợpTài liệu học đa phương thức [@baltruvsaitis2018multimodal; @liang2024foundations] 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 @radford2021learning).```{python}#| label: load-multimodal-data#| eval: false# DataCore.vn APIfrom datacore import DataCoredc = DataCore()# Load aligned multimodal dataset# Each observation: firm × quarter with all available modalities# Tabular: financial statementsfinancials = dc.get_firm_financials( start_date="2014-01-01", end_date="2024-12-31", frequency="quarterly")# Text: management discussion from annual reportsreport_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 volumedaily_data = dc.get_daily_returns( start_date="2014-01-01", end_date="2024-12-31")# Target: forward quarterly returnsquarterly_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()}")```## Căn chỉnh biểu diễn### Bài toán căn chỉnhCá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).### Căn chỉnh đối chiếu: CLIP cho tài chínhKhung Contrastive Language-Image Pre-training (CLIP) của @radford2021learning 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]$$ {#eq-clip-loss}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 [@oord2018representation], đồ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.```{python}#| label: contrastive-alignment#| eval: falseclass 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 projectionself.text_proj = nn.Sequential( nn.Linear(text_dim, proj_dim), nn.LayerNorm(proj_dim), nn.GELU(), nn.Linear(proj_dim, proj_dim) )# Image projectionself.image_proj = nn.Sequential( nn.Linear(image_dim, proj_dim), nn.LayerNorm(proj_dim), nn.GELU(), nn.Linear(proj_dim, proj_dim) )# Learnable temperatureself.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) /2return z_text, z_image, lossdef 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)```### 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$$ {#eq-projection}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$$ {#eq-alignment-loss}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}}$$ {#eq-total-loss}```{python}#| label: multi-projection#| eval: falseclass 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 encodersself.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 inself.encoders and x isnotNone: embeddings[name] =self.encoders[name](x)return embeddingsdef compute_alignment_loss(self, embeddings):"""Pairwise MSE alignment across all available modalities.""" names =list(embeddings.keys())iflen(names) <2:return torch.tensor(0.0, device=next(self.parameters()).device) loss = torch.tensor(0.0, device=next(self.parameters()).device) n_pairs =0for i inrange(len(names)):for j inrange(i +1, len(names)): loss += F.mse_loss( embeddings[names[i]], embeddings[names[j]] ) n_pairs +=1return loss / n_pairs if n_pairs >0else loss```## Kiến trúc kết hợp cho dự báo lợi nhuận### Bộ mã hóa đơn phương thứcTrướ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.```{python}#| label: unimodal-encoders#| eval: falseclass 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):returnself.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):returnself.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):returnself.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 layersself.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 attentionself.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)returnself.fc(x)```### Kết hợp sớmKế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.```{python}#| label: early-fusion#| eval: falseclass 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 inself.encoders.items():if name in inputs and inputs[name] isnotNone: 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)returnself.head(combined).squeeze(-1)```### Kết hợp muộnKế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).```{python}#| label: late-fusion#| eval: falseclass 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 = combinationself.n_modalities =len(encoders)# Per-modality prediction headsself.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 inputself.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 inself.encoders.items():if name in inputs and inputs[name] isnotNone: emb = encoder(inputs[name]) pred =self.heads[name](emb).squeeze(-1) predictions[name] = pred embeddings[name] = embelse: 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)ifself.combination =="average":return pred_stack.mean(dim=-1)elifself.combination =="learned": weights = F.softmax(self.weights, dim=0)return (pred_stack * weights).sum(dim=-1)elifself.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."""ifself.combination =="learned":return F.softmax(self.weights, dim=0).detach().cpu().numpy()returnNone```### Kết hợp chú ý chéoKế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 [@alayrac2022flamingo] 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')}$$ {#eq-cross-attention-detail}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'$.```{python}#| label: cross-attention-fusion#| eval: falseclass 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() ==2else query kv = key_value.unsqueeze(1) if key_value.dim() ==2else 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() ==2else out, attn_weightsclass 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 otherself.cross_attn_layers = nn.ModuleList()for _ inrange(n_layers): layer = nn.ModuleDict()for m inself.modality_names:for m_prime inself.modality_names:if m != m_prime: layer[f"{m}_to_{m_prime}"] = CrossAttentionBlock( enc_dim, n_heads )self.cross_attn_layers.append(layer)# Prediction headself.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 inself.encoders.items():if name in inputs and inputs[name] isnotNone: 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 inself.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 inself.modality_names], dim=-1 )returnself.head(combined).squeeze(-1), all_attn_weights```### Thí nghiệm so sánhBâ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.```{python}#| label: multimodal-dataset#| eval: falseclass 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_dfself.text = text_embeddingsself.image = image_featuresself.ts = ts_featuresself.returns = returnsself.tickers = tickersself.dates = datesdef__len__(self):returnlen(self.returns)def__getitem__(self, idx): sample = {"tabular": torch.tensor(self.tabular[idx], dtype=torch.float32 ) ifself.tabular[idx] isnotNoneelseNone,"text": torch.tensor(self.text[idx], dtype=torch.float32 ) ifself.text[idx] isnotNoneelseNone,"image": torch.tensor(self.image[idx], dtype=torch.float32 ) ifself.image[idx] isnotNoneelseNone,"ts": torch.tensor(self.ts[idx], dtype=torch.float32 ) ifself.ts[idx] isnotNoneelseNone,"return": torch.tensor(self.returns[idx], dtype=torch.float32 ),"ticker": self.tickers[idx],"date": self.dates[idx] }return sampledef 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]ifall(v isnotNonefor v in values): result[mod] = torch.stack(values)elifany(v isnotNonefor v in values):# Fill None with zeros, matching shape of non-None entries ref =next(v for v in values if v isnotNone) filled = [v if v isnotNoneelse torch.zeros_like(ref)for v in values] result[mod] = torch.stack(filled)else: result[mod] =Nonereturn result``````{python}#| label: prepare-aligned-data#| eval: false# 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)iflen(subset) < lookback //2:returnNone features = subset[["ret", "volume_log", "volatility_20d","spread", "turnover"]].values# Pad if shorter than lookbackiflen(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 datesprint("Preparing aligned multimodal dataset...")``````{python}#| label: training-loop#| eval: falsedef 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 inrange(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)ifisinstance(output, tuple): predictions, attn_weights = outputelse: predictions = output loss = F.mse_loss(predictions, targets)# Optional alignment lossif alignment_weight >0andhasattr(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)ifisinstance(output, tuple): predictions, _ = outputelse: 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) iflen(val_preds) >10else0 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 stoppingif 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 =0else: epochs_no_improve +=1if 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"]) }``````{python}#| label: model-comparison#| eval: falsedef 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 =64for fold, (train_idx, test_idx) inenumerate( 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 encodersdef 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 baselinesfor 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)``````{python}#| label: tbl-fusion-comparison#| eval: false#| tbl-cap: "Dự báo lợi nhuận ngoài mẫu: Đơn phương thức so với đa phương thức"# 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``````{python}#| label: fig-fusion-comparison#| eval: false#| fig-cap: "R² ngoài mẫu theo các chiến lược kết hợp và các nhóm kiểm định chuỗi thời gian"# (# 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")# )```## Xử lý các phương thức bị thiếu### Vấn đề phương thức bị thiếuTrong 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:| 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% |: Mức độ sẵn có của các phương thức trong dữ liệu thị trường Việt Nam {#tbl-modality-coverage}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.### 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ả.```{python}#| label: missing-modality-handler#| eval: falseclass 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_probdef forward(self, modality_inputs):ifnotself.training:return modality_inputs result = {}for name, tensor in modality_inputs.items():if tensor isnotNoneand torch.rand(1).item() >self.drop_prob: result[name] = tensorelse: result[name] =None# Ensure at least one modality remainsifall(v isNonefor v in result.values()):# Keep the first available modalityfor name, tensor in modality_inputs.items():if tensor isnotNone: result[name] = tensorbreakreturn resultclass 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 modalitiesself.defaults = nn.ParameterDict({ name: nn.Parameter(torch.randn(enc_dim) *0.01)for name in encoders })# Modality presence indicator embeddingself.presence_proj = nn.Linear(self.n_modalities, enc_dim)# Modality dropoutself.mod_dropout = ModalityDropout(drop_prob)# Attention-based aggregationself.attn_pool = nn.Sequential( nn.Linear(enc_dim, 1), nn.Softmax(dim=0) )# Prediction headself.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 inself.modality_names:if name in inputs and inputs[name] isnotNone: 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 isnotNone ) 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)returnself.head(combined).squeeze(-1)```## Hiểu tài liệu đa phương thức### Báo cáo thường niên như một đối tượng đa phương thứcMộ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 @kim2022ocr, đượ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}})$$ {#eq-donut}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.```{python}#| label: multimodal-document#| eval: falseclass 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 embeddingself.img_proj = nn.Sequential( nn.Linear(img_dim, hidden_dim), nn.LayerNorm(hidden_dim) )# Modality type embeddingself.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] tokenself.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] representationreturn output[:, 0, :]```### 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ứcVớ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).```{python}#| label: financial-extraction-head#| eval: falseclass 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_encoderself.fields = fields# Per-field extraction headsself.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 headself.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 inself.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```## Mô hình bất ngờ lợi nhuận đa phương thức### Kiến trúcBâ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}}$$ {#eq-sue}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.```{python}#| label: earnings-surprise-data#| eval: false# Construct earnings surprise datasetearnings = dc.get_earnings_announcements( start_date="2016-01-01", end_date="2024-12-31")# Standardized Unexpected Earningsearnings["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] windowpre_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 endpre_ann_image = satellite_features.copy()# Time series: 60 trading days before announcement# (Pre-computed above)# Tabular: most recent quarterly financialspre_ann_tabular = financials[tabular_features + ["ticker", "quarter_date"]]print(f"Earnings announcements: {len(earnings)}")print(f"With text features: {len(pre_ann_text)}")``````{python}#| label: earnings-model#| eval: falseclass 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 encodersself.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 dropoutself.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 headself.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 isnotNoneelseNone h_txt =self.text_enc(text) if text isnotNoneelseNone h_img =self.img_enc(image) if image isnotNoneelseNone h_ts =self.ts_enc(ts) if ts isnotNoneelseNone# Cross-attention pairs (if both available)if h_txt isnotNoneand h_ts isnotNone: h_txt_enriched, _ =self.text_ts_attn(h_txt, h_ts)else: h_txt_enriched = h_txtif h_tab isnotNoneand h_img isnotNone: 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 inenumerate([h_tab_enriched, h_txt_enriched, h_img, h_ts]):if h isnotNone: 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 isnotNone ) embeddings.append( torch.zeros(batch_size, h_tab.shape[-1]if h_tab isnotNoneelse64, 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)returnself.head(combined).squeeze(-1)```### Phân tích tầm quan trọng của phương thứcMộ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).```{python}#| label: modality-importance#| eval: falsedef 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] ifisinstance(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 modalityelse: inputs[k] = batch[k] targets = batch["return"] output = model(inputs) pred = output[0] ifisinstance(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)``````{python}#| label: tbl-ablation#| eval: false#| tbl-cap: "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"# ablation_df = ablation_study(model, test_loader, modality_names)# ablation_df.round(4)``````{python}#| label: fig-modality-importance#| eval: false#| fig-cap: "Trọng số tầm quan trọng của phương thức được học qua các epoch huấn luyện"# 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))# )```## Các mô hình đa phương thức lớn cho phân tích tài chính### Đư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```{python}#| label: vlm-financial-prompting#| eval: falsedef 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 queriesFINANCIAL_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." )}```### Phân tích đa phương thức tăng cường truy xuấtVớ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ức3. **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ính4. **Kết hợp**: Tổng hợp bằng chứng từ các phương thức5. **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ợ```{python}#| label: rag-multimodal#| eval: falseclass MultimodalRAG:""" Retrieval-Augmented Generation with multimodal evidence. """def__init__(self, datacore_client, vlm_model=None):self.dc = datacore_clientself.vlm = vlm_modeldef retrieve_evidence(self, ticker, date, modalities=None):""" Retrieve all available evidence for a firm at a given date. """ evidence = {}if modalities isNoneor"tabular"in modalities: evidence["tabular"] =self.dc.get_firm_financials( ticker=ticker, end_date=date, n_quarters=4 )if modalities isNoneor"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 isNoneor"image"in modalities: evidence["image"] =self.dc.get_satellite_images( ticker=ticker, date=date, lookback_months=6 )if modalities isNoneor"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 evidencedef 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 signalif"tabular"in evidence and evidence["tabular"] isnotNone: 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 signalif"text"in evidence and evidence["text"] isnotNone:# Aggregate sentiment from PhoBERT texts = evidence["text"]iflen(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_increasingelse"deteriorating"if texts["sentiment_score"].is_monotonic_decreasingelse"mixed" ) }# Time series signalif"ts"in evidence and evidence["ts"] isnotNone: 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```## Các lưu ý về đánh giá và triển khai### Quy trình đánh giá cho các mô hình tài chính đa phương thứcCá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.| 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 @diebold2002comparing để 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 |: 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 {#tbl-evaluation}### Ngân sách tính toánCác mô hình đa phương thức tốn nhiều tài nguyên tính toán. @tbl-compute-budget cung cấp các ước tính về bậc độ lớn cho thị trường cổ phiếu Việt Nam.| 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) |: Ngân sách tính toán cho quy trình đa phương thức {#tbl-compute-budget}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.<!-- ## Bài tập1. **Tiền huấn luyện đối chiếu cho tài chính Việt Nam.** Triển khai căn chỉnh đối chiếu FinancialCLIP cho dữ liệu ghép cặp (văn bản báo cáo thường niên, ảnh vệ tinh). Huấn luyện trên dữ liệu 2014-2020 và đánh giá chất lượng căn chỉnh trên giai đoạn 2021-2024 bằng cách đo độ chính xác truy xuất zero-shot: cho một mô tả văn bản, mô hình có truy xuất đúng ảnh vệ tinh không (và ngược lại)? Báo cáo Recall\@1, Recall\@5 và Recall\@10.2. **Tìm kiếm kiến trúc kết hợp.** Triển khai các chiến lược kết hợp bổ sung sau, ngoài ba chiến lược trong chương này: (a) kết hợp song tuyến tính với xấp xỉ hạng thấp, (b) hỗn hợp các chuyên gia với $2^M - 1$ mạng chuyên gia, và (c) điều kiện hóa FiLM (Feature-wise Linear Modulation). So sánh cả sáu chiến lược trong dự báo lợi nhuận hằng quý kỳ tới bằng cùng quy trình kiểm định chéo chuỗi thời gian. Kiến trúc nào đạt tỷ số Sharpe ngoài mẫu tốt nhất sau chi phí giao dịch?3. **Độ vững trước phương thức bị thiếu.** Sử dụng RobustFusionModel, đánh giá một cách hệ thống mức suy giảm khi các phương thức bị loại bỏ. Bắt đầu với cả bốn phương thức và loại bỏ dần theo mọi thứ tự có thể. Vẽ bề mặt hiệu suất: $R^2$ như một hàm của số lượng và loại phương thức sẵn có. Phương thức đơn lẻ nào mang nhiều thông tin nhất? Cặp nào? Việc thêm phương thức thứ ba và thứ tư có cho thấy lợi ích giảm dần không?4. **Phân tích cuộc họp công bố kết quả kinh doanh đa phương thức.** Nếu có âm thanh hoặc bản ghi chép cuộc họp công bố kết quả kinh doanh từ DataCore.vn, hãy mở rộng mô hình bất ngờ lợi nhuận đa phương thức để thêm phương thức thứ năm: các đặc trưng âm thanh (giọng điệu, tốc độ nói, mẫu hình ngắt nghỉ). Sử dụng mô hình wav2vec2 để trích xuất các vector nhúng âm thanh. Giọng điệu của CEO có bổ sung sức dự báo ngoài văn bản bản ghi chép không? Triển khai phương pháp của @mayew2012power để so sánh tín hiệu giọng nói và tín hiệu văn bản.5. **Bộ chuẩn hiểu tài liệu.** Xây dựng một bộ dữ liệu chuẩn gồm 200 trang báo cáo thường niên của Việt Nam với các chỉ tiêu tài chính được chú thích thủ công (doanh thu, giá vốn hàng bán, lợi nhuận ròng, tổng tài sản, tổng vốn chủ sở hữu, tổng nợ). Đánh giá ba cách tiếp cận: (a) OCR + trích xuất dựa trên quy tắc (Chương 61), (b) LayoutLMv3 được tinh chỉnh trên 100 trang, và (c) trích xuất VLM zero-shot qua GPT-4V hoặc Gemini. Báo cáo tỷ lệ khớp chính xác ở cấp chỉ tiêu và sai số phần trăm tuyệt đối trung bình đối với các giá trị số.6. **Xây dựng nhân tố đa phương thức.** Sử dụng mô hình kết hợp có hiệu suất tốt nhất, tạo các điểm số đa phương thức ở cấp doanh nghiệp-quý. Phân loại các doanh nghiệp thành các nhóm ngũ phân vị theo điểm số này và tính lợi nhuận danh mục trọng số bằng nhau và trọng số theo vốn hóa. Nhân tố mua–bán khống đa phương thức có được định giá theo chiều cắt ngang (cross-section; Fama-MacBeth) không? Nó có bao hàm nhân tố tâm lý văn bản từ Chương 60 hoặc nhân tố dựa trên vệ tinh từ Chương 61 không? Báo cáo kết quả kiểm định bao hàm (spanning). -->## Tóm tắtChươ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.