ESG-SASB 匹配系統改善計劃
目標: 提升 Test F1 Score 從 0.085 至 0.35+ 當前狀態: Train F1=0.175, Test F1=0.085 分析日期: 2025-12-11
一、現狀分析摘要
1.1 專案概述
這是一個 ESG-SASB 匹配系統,使用 RAG 架構將 ESG 報告中的文本段落匹配到 SASB 指標:
輸入: ESG 報告 PDF 頁面 + SASB 標準定義
輸出: 邊界框座標 + SASB 指標代碼
評估: F1 Score (IoU ≥ 0.5)
1.2 當前最佳配置
參數 值 說明
LLM qwen3:8b (Ollama) 優於 gemma3:12b (+27% F1)
score_threshold 0.50 語義相似度門檻
llm_threshold 6 LLM 信心分數門檻
bi_encoder BAAI/bge-m3 多語言向量模型
reranker BAAI/bge-reranker-v2-m3 交叉編碼器
1.3 錯誤分佈分析
類型 數量 百分比 說明
✅ True Positive 12 15.8% Code 正確 + IoU ≥ 0.5
🟡 Code Correct, Low IoU 18 23.7% 0.707
coord-norm 在 Train 有效 (+0.008) 但破壞 Test (-0.043)
高 False Negative Rate
53/76 = 69.7% 的 GT 標籤未被預測
Retrieval 召回不足 + LLM 過濾太嚴
Train/Test 分佈差異
Train F1=0.175 vs Test F1=0.085 (相差 51%)
可能存在過擬合現象
二、改善策略(依優先順序排列)
🔴 P0: 修復座標系統問題 (預期 +0.080.10 F1)
問題根因:
GT 使用 PDF points (頁寬 842)
OCR 輸出使用 Image pixels (頁寬 ~963 或更大)
Train/Test 的 page_width 分佈不同
解決方案:
def normalize_bbox_to_pdf_points(bbox, page_width, page_height,
target_width=842, target_height=595):
if page_width > 900: # 檢測 Image pixels
scale_x = target_width / page_width
scale_y = target_height / page_height
return [
int(bbox[0] * scale_x), int(bbox[1] * scale_y),
int(bbox[2] * scale_x), int(bbox[3] * scale_y)
]
return bbox
關鍵: 必須根據每個樣本的 page_width 動態計算 scale,而非使用固定值。 修改檔案:
main.py - 推論管道中加入座標正規化
config.py - 新增座標正規化配置選項
🔴 P1: 提升 Retrieval Recall (預期 +0.030.05 F1)
當前配置:
score_threshold = 0.50
reranker_top_k = 10
top_k_candidates_per_metric = 5
建議調整:
score_threshold = 0.45 # 降低語義門檻
score_threshold_english = 0.43 # 英文報告更寬鬆
reranker_top_k = 15 # 增加候選數量
top_k_candidates_per_metric = 8 # 每指標更多候選
修改檔案: config.py
🟠 P2: 優化 LLM 評分門檻 (預期 +0.020.04 F1)
當前: confidence_threshold = 6 建議測試:
嘗試 confidence_threshold = 5
觀察 False Positive 是否大幅增加
風險: 可能增加 False Positives,需要平衡 Precision/Recall
🟠 P3: 改善 Segment 覆蓋率 (預期 +0.020.04 F1)
問題: 部分 GT 標籤對應的 segment 未被提取 解決方案:
檢查 esg_segments_v2.jsonl 的覆蓋率
補充缺失的 segment (可能需要重新 OCR)
或使用 v4 (PyMuPDF) 作為補充來源
🟡 P4: BBox 後處理優化 (預期 +0.010.03 F1)
當前問題: 預測框常常太小或偏移 建議調整:
min_bbox_area = 20000 # 從 25000 降低
expand_margin = 100 # 從 80 增加
expand_ratio = 0.03 # 從 0.02 增加
max_merged_ratio = 0.60 # 從 0.50 增加
🟡 P5: Embedding 模型優化 (預期 +0.020.05 F1)
備選模型:
BAAI/bge-large-zh-v1.5 - 中文優化
intfloat/multilingual-e5-large - 多語言
sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
測試方法: 在相同配置下比較不同模型的 Recall@10
🟢 P6: Ensemble 策略 (進階,預期 +0.020.05 F1)
結合多個配置的預測結果:
High-Recall 配置 (低 threshold)
High-Precision 配置 (高 threshold)
不同 Embedding 模型
三、Optuna 自動超參數搜尋方案
3.1 為何使用 Optuna
自動化: 自動探索參數空間,比手動網格搜尋高效
智能採樣: 使用 TPE (Tree-structured Parzen Estimator) 演算法,根據歷史結果智能選擇下一組參數
剪枝機制: 可提前停止表現差的 trial,節省計算資源
視覺化: 提供參數重要性分析、優化歷程等視覺化功能
3.2 需要搜尋的超參數
參數 搜尋範圍 類型 說明
score_threshold [0.35, 0.60] float 語義相似度門檻
score_threshold_english [0.30, 0.55] float 英文報告門檻
llm_threshold [4, 8] int LLM 信心分數門檻
reranker_top_k [5, 20] int Reranker 保留數量
top_k_candidates_per_metric [3, 10] int 每指標候選數
bm25_boost_value [0.01, 0.10] float BM25 加分值
min_bbox_area [15000, 35000] int 最小 bbox 面積
expand_margin [50, 150] int bbox 擴展邊距
expand_ratio [0.01, 0.05] float bbox 擴展比例
max_merged_ratio [0.40, 0.70] float 最大合併比例
3.3 Optuna 腳本實作
新建檔案: scripts/optuna_search.py
#!/usr/bin/env python3
"""
Optuna 超參數自動搜尋腳本
Usage: python scripts/optuna_search.py --n-trials 100 --study-name esg-sasb-v1
"""
import os import sys import optuna import pandas as pd import tempfile import subprocess from datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(file)))) from scripts.score import score
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(file))) SOLUTION_PATH = os.path.join(PROJECT_ROOT, "data", "train_solution.csv")
def objective(trial: optuna.Trial) -> float: """Optuna 目標函數 - 最大化 F1 Score"""
# 定義超參數搜尋空間
params = {
"score_threshold": trial.suggest_float("score_threshold", 0.35, 0.60),
"score_threshold_english": trial.suggest_float("score_threshold_english", 0.30, 0.55),
"llm_threshold": trial.suggest_int("llm_threshold", 4, 8),
"reranker_top_k": trial.suggest_int("reranker_top_k", 5, 20),
"top_k_candidates_per_metric": trial.suggest_int("top_k_candidates_per_metric", 3, 10),
"bm25_boost_value": trial.suggest_float("bm25_boost_value", 0.01, 0.10),
"min_bbox_area": trial.suggest_int("min_bbox_area", 15000, 35000, step=5000),
"expand_margin": trial.suggest_int("expand_margin", 50, 150, step=10),
"expand_ratio": trial.suggest_float("expand_ratio", 0.01, 0.05),
"max_merged_ratio": trial.suggest_float("max_merged_ratio", 0.40, 0.70),
}
# 建立臨時輸出檔案
with tempfile.NamedTemporaryFile(suffix=".csv", delete=False) as f:
output_path = f.name
try:
# 執行推論
cmd = [
"python", os.path.join(PROJECT_ROOT, "main.py"),
"--mode", "train",
"--output", output_path,
"--score-threshold", str(params["score_threshold"]),
"--score-threshold-english", str(params["score_threshold_english"]),
"--llm-threshold", str(params["llm_threshold"]),
"--reranker-top-k", str(params["reranker_top_k"]),
"--top-k-candidates", str(params["top_k_candidates_per_metric"]),
"--bm25-boost", str(params["bm25_boost_value"]),
"--min-bbox-area", str(params["min_bbox_area"]),
"--expand-margin", str(params["expand_margin"]),
"--expand-ratio", str(params["expand_ratio"]),
"--max-merged-ratio", str(params["max_merged_ratio"]),
"--skip-version-increment", # 避免版本號混亂
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=3600)
if result.returncode != 0:
print(f"Trial {trial.number} failed: {result.stderr}")
return 0.0
# 計算 F1 Score
solution_df = pd.read_csv(SOLUTION_PATH)
submission_df = pd.read_csv(output_path)
f1 = score(solution_df, submission_df)
# 記錄中間結果
trial.set_user_attr("f1_score", f1)
trial.set_user_attr("params", params)
return f1
except Exception as e:
print(f"Trial {trial.number} error: {e}")
return 0.0
finally:
# 清理臨時檔案
if os.path.exists(output_path):
os.remove(output_path)
def main(): import argparse parser = argparse.ArgumentParser() parser.add_argument("--n-trials", type=int, default=100, help="Number of trials") parser.add_argument("--study-name", type=str, default="esg-sasb", help="Study name") parser.add_argument("--storage", type=str, default=None, help="Optuna storage URL") parser.add_argument("--timeout", type=int, default=None, help="Timeout in seconds") args = parser.parse_args()
# 建立 Study (使用 SQLite 持久化)
storage = args.storage or f"sqlite:///{PROJECT_ROOT}/optuna_studies.db"
study = optuna.create_study(
study_name=args.study_name,
storage=storage,
direction="maximize", # 最大化 F1
load_if_exists=True, # 支援中斷續跑
sampler=optuna.samplers.TPESampler(seed=42),
pruner=optuna.pruners.MedianPruner(),
)
# 執行優化
study.optimize(
objective,
n_trials=args.n_trials,
timeout=args.timeout,
show_progress_bar=True,
)
# 輸出結果
print("\n" + "=" * 60)
print("🏆 Best Trial:")
print(f" F1 Score: {study.best_value:.4f}")
print(f" Parameters:")
for key, value in study.best_params.items():
print(f" {key}: {value}")
# 儲存最佳參數
best_params_path = os.path.join(PROJECT_ROOT, "results", f"best_params_{args.study_name}.json")
import json
with open(best_params_path, "w") as f:
json.dump({
"f1_score": study.best_value,
"params": study.best_params,
"timestamp": datetime.now().isoformat(),
}, f, indent=2)
print(f"\n✅ Best parameters saved to: {best_params_path}")
# 參數重要性分析
try:
importance = optuna.importance.get_param_importances(study)
print("\n📊 Parameter Importance:")
for param, imp in sorted(importance.items(), key=lambda x: x[1], reverse=True):
print(f" {param}: {imp:.4f}")
except Exception as e:
print(f"Could not compute importance: {e}")
if name == "main": main() 3.4 需要修改 main.py 支援命令列參數 在 main.py 中加入以下參數支援:
parser.add_argument("--score-threshold", type=float, help="Bi-encoder similarity threshold") parser.add_argument("--score-threshold-english", type=float, help="English segment threshold") parser.add_argument("--llm-threshold", type=int, help="LLM confidence threshold") parser.add_argument("--reranker-top-k", type=int, help="Reranker top-k") parser.add_argument("--top-k-candidates", type=int, help="Top-k candidates per metric") parser.add_argument("--bm25-boost", type=float, help="BM25 boost value") parser.add_argument("--min-bbox-area", type=int, help="Minimum bbox area") parser.add_argument("--expand-margin", type=int, help="Expand margin") parser.add_argument("--expand-ratio", type=float, help="Expand ratio") parser.add_argument("--max-merged-ratio", type=float, help="Max merged ratio") parser.add_argument("--output", type=str, help="Output path (override auto-generated)") parser.add_argument("--skip-version-increment", action="store_true", help="Skip version increment") 3.5 Optuna 視覺化分析
import optuna.visualization as vis
study = optuna.load_study(study_name="esg-sasb", storage="sqlite:///optuna_studies.db")
fig = vis.plot_optimization_history(study) fig.write_html("results/optuna_history.html")
fig = vis.plot_param_importances(study) fig.write_html("results/optuna_importance.html")
fig = vis.plot_parallel_coordinate(study) fig.write_html("results/optuna_parallel.html")
fig = vis.plot_slice(study) fig.write_html("results/optuna_slice.html") 四、實驗執行計劃 Phase 1: 環境準備 + 座標修復 (Day 1)
pip install optuna optuna-dashboard plotly kaleido
python main.py --mode train --score-threshold 0.50 --llm-threshold 6 python scripts/score.py data/train_solution.csv results/train_vXX_selective_merge.csv Phase 2: Optuna 超參數搜尋 (Day 2-3)
python scripts/optuna_search.py --n-trials 10 --study-name test-run
python scripts/optuna_search.py --n-trials 100 --study-name esg-sasb-v1
optuna-dashboard sqlite:///optuna_studies.db Phase 3: 結果驗證 + Test 提交 (Day 4)
python main.py --mode train
--score-threshold <best_score_th>
--llm-threshold <best_llm_th>
# ... 其他最佳參數
python main.py --mode test
--score-threshold <best_score_th>
--llm-threshold <best_llm_th>
Phase 4: 進階優化 (可選)
測試不同 Embedding 模型
實作 Ensemble 策略
持續 Optuna 搜尋 (更多 trials)
五、預期效果
改進項目 預期提升 累計 Train F1 累計 Test F1
當前基線 - 0.175 0.085
P0 座標修復 +0.08 0.255 0.165
Optuna 自動調參 +0.050.10 0.3050.355 0.2150.265
進階優化 (Ensemble等) +0.020.05 0.3250.405 0.2350.315
目標 - 0.38+ 0.35+
六、需要修改/新增的關鍵檔案
檔案 修改內容
main.py 1. 動態座標正規化邏輯
2. 新增命令列參數支援 (Optuna 需要)
config.py threshold 參數調整
scripts/optuna_search.py 新建 - Optuna 超參數搜尋腳本
scripts/04_rag_baseline_infer_v64_selective_merge.py 推論腳本更新
七、重要注意事項
不要在 Test 上使用 --coord-norm - 會破壞結果
不要使用 --multi-label - 降低 F1
Optuna 搜尋時間估算: 每 trial 約 2-5 分鐘 (取決於 LLM 推論速度)
動態計算 scale - 不要使用固定值
先在 Train 驗證再跑 Test - 避免浪費時間
使用 SQLite 儲存 Study - 支援中斷續跑
八、實施順序建議
✅ Day 1: 修復座標系統問題 (P0) + 安裝 Optuna
✅ Day 2: 修改 main.py 支援命令列參數 + 建立 optuna_search.py
✅ Day 3: 執行 Optuna 超參數搜尋 (100 trials)
✅ Day 4: 使用最佳參數在 Test 上執行,提交結果
⏳ Day 5+: 進階優化 (不同 Embedding、Ensemble 等)
九、Optuna 搜尋策略建議
9.1 搜尋空間優化
根據先前實驗結果,可以縮小某些參數的搜尋範圍:
參數 原始範圍 建議範圍 理由
score_threshold [0.35, 0.60] [0.40, 0.55] 0.50 附近表現較好
llm_threshold [4, 8] [5, 7] 太低增加 FP,太高增加 FN
9.2 分階段搜尋
Stage 1: 先搜尋 Retrieval 相關參數 (score_threshold, reranker_top_k)
Stage 2: 再搜尋 BBox 後處理參數 (expand_margin, max_merged_ratio)
Stage 3: 微調其他參數
9.3 多目標優化 (可選)
如果想同時優化 Train 和 Test 效能,可以使用 Optuna 的多目標優化:
study = optuna.create_study(
directions=["maximize", "maximize"], # Train F1, Test F1 (需要 validation split)
)
預計可在 3-4 天內完成 Optuna 搜尋,達到 Test F1 > 0.25 的初步目標。