Skip to content

Latest commit

 

History

History
375 lines (341 loc) · 15.2 KB

File metadata and controls

375 lines (341 loc) · 15.2 KB

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% ⚠️ 關鍵問題 🔴 Code Mismatch 15 19.7% 預測錯誤 Code ❌ False Negative 53 69.7% ⚠️ 最大問題 ⚠️ False Positive 21 27.6% 誤報 1.4 核心瓶頸 座標系統不匹配 (最關鍵) 18 個樣本 Code 正確但 IoU < 0.5 Train OCR scale: ~0.874, Test OCR scale: 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 分佈不同 解決方案:

在 main.py 中實作動態座標正規化

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 中加入以下參數支援:

在 argparse 部分加入

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

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)

Step 1: 安裝 Optuna

pip install optuna optuna-dashboard plotly kaleido

Step 2: 實作動態座標正規化

修改 main.py,根據 page_width 動態計算 scale

Step 3: 驗證座標修復

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)

Step 1: 小規模測試 (確認腳本正常)

python scripts/optuna_search.py --n-trials 10 --study-name test-run

Step 2: 完整搜尋 (100 trials,約 3-6 小時)

python scripts/optuna_search.py --n-trials 100 --study-name esg-sasb-v1

Step 3: 查看即時進度 (另開終端)

optuna-dashboard sqlite:///optuna_studies.db Phase 3: 結果驗證 + Test 提交 (Day 4)

Step 1: 使用最佳參數在 Train 驗證

python main.py --mode train
--score-threshold <best_score_th>
--llm-threshold <best_llm_th>
# ... 其他最佳參數

Step 2: 在 Test 上執行

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 的初步目標。