PPO 小註解

Proximal Policy Optimization 是一個 fine-tune Model 的方式, 另一個主流方式是 DPO (Direct Preference Optimization). 這邊主要是整理課程影片中 PPO 的公式 (只用大約 2 分鐘超高速閃過…), 以便有空時可以查詢.

[基本架構]

有一個 LLM model, 和可調整的參數: θ, query: X, response: Y, 所有輸出入組合 rollout 表示為 (X, Y).

因為我們要微調這個 model, 所以要給一個獎勵函數 (reward function): r(X,Y), 用來調整模型 πθ (X,Y).

獎勵是對所有輸出的 Y 取期望值

EY~πθ (X,Y) [r(X,Y)], 其中 Y~πθ (X,Y), 表示 Y 在 πθ (X,Y) 的輸出組合.

也對每個 query X 計算

EX~D[EY~πθ (X,Y) [r(X,Y)]] ——————— (1)

其中 X~D, D 表示 X 的散度 (Divergence), 依此類推.

我們找出一個得到最多獎勵的那組參數, 就算是 fine-tune 完成.

π* (X,Y) = arg maxπ {EX~D[EY~πθ (X,Y) [r(X,Y)]]}, {} 裡面就是上面那包.

[Reference model]

做得好不好, 我們要有一個 reference model 來當評審.

πref (X,Y)

Model 的輸出愈接近 reference model 就表示學得愈好.

[懲罰值]

KL 是 Kullback-Leibler 兩個人名的縮寫. KL penality coefficient 是它們衡量懲罰的函數. 基於 reference model πref (X,Y) 下, 對 πθ (X,Y) 產生的懲罰 DKL 表示為

DKLθ (X,Y) || πref (X,Y)]

[綜合期望值和懲罰值]

π* (X,Y) = arg maxπ {EX~D[EY~πθ (X,Y) [r(X,Y)]] – β * 懲罰值} =>

π* (X,Y) = arg maxπ {EX~D[EY~πθ (X,Y) [r(X,Y)]] – β DKLθ (X,Y) || πref (X,Y)]}


為了要知道往哪個方向調整, 我們要找 θ 的梯度.

[只看 reward function]

這是 reward function 在特定 model 參數 θ 的期望值

E[rY ] = EY~πθ (X,Y) [r(X,Y)]

將它表示為解析函數 [1], 期望值等於所有 Y 可能的值乘上它的機率的和

E[rY ] = ∑Y r(X,Y)πθ (Y|X) —— (2)

最優化的參數 θ⌃ 就是所有 θ 中 reward 最好的. (我打不出 hat theta, 所以把帽子放右邊)

θ⌃ = arg maxθ [E[rY ]] = arg maxθ [∑Y r(X,Y)πθ (Y|X)]

對式子 (2) 取 θ 的梯度,

θ E[rY ] = ∑Y r(X,Y)θ πθ (Y|X)] ——– (3)

按照微積分的 chain rule [3]

θ log (πθ (Y|X)) = θπθ (Y|X) / πθ (Y|X) =>

θπθ (Y|X) = θ log (πθ (Y|X)) * πθ (Y|X) —– (4)

把這個變長的 (4) 帶回去 (3),

θ E[rY ] = ∑Y r(X,Y)θ πθ (Y|X)] =>

θ E[rY ] = ∑Y r(X,Y)θ log (πθ (Y|X)) * πθ (Y|X) =>

θ E[rY ] = EY~πθ (Y|X) r(X,Y)θ log (πθ (Y|X))

再把 X 考慮進去, 就得到

Ex~D [θ E[rY ] ] = θ Ex~D [ E[rY ] ]

意思是說, 當 β = 0, 忽略掉懲罰的話, 對式子 (1) 取梯度, 等效只對 reward function 取梯度. 反之亦然.

這有什麼意義呢? 我請 AI 幫我解釋. 我試了幾次都沒有它講得好.

θ E[rY ] = EY~πθ (Y|X) r(X,Y)θ log (πθ (Y|X)) 這個結果是 策略梯度定理(Policy Gradient Theorem)[2] 的核心公式。其意義在於:

  • 將梯度轉換為期望形式:原始梯度需遍歷所有可能的輸出 θY(計算量巨大),但此公式表明:梯度可通過從策略 πθ   中採樣 Y 來近似計算。
  • 避開解析計算:無需知道所有 Y 的機率分佈,只需對採樣的 Y 計算  r(X,Y)θ log (πθ (Y|X)) 的平均值。

它與 Monte Carlo Method 的相似處在於: 透過採樣直接估計期望值,無需精確計算所有可能的 Y.

蒙特卡羅方法收斂速度為 O(1/√N​), N 夠大就好. 而窮舉法不可行.

最後再筆記一下懲罰項.

Kullback-Leibler 方法其實是要避免訓練後新的參數和舊的參數偏差太遠. 以至於學會新的東西就忘了舊的 – 災難性遺忘 (Catastrophic Forgetting), 和 reward 無關. 因此討論原理時可以忽略它.

但它對於穩定系統非常重要, 其主要角色是作為正則化項 (Regularizer), 將策略更新限制在信賴域 (Trust Region) 內. 在PPO算法中,KL 散度還會動態調整更新步長. 當 DKL​ 超過閾值時自動縮小學習率,此設計本質上將 “避免遺忘" 和 “促進學習" 綁定為同一過程。

[REF]

  1. 解析函數 – 維基百科,自由的百科全書
  2. https://en.wikipedia.org/wiki/Policy_gradient_method
  3. Slide 1

GGUF 小註解

拜台幣升值 10% 左右之賜, 我在 Q2 的投資差點要 GG. 我期望不高, 只要月底能跟 Q1 對齊, 也算是有 10% 看不見的成長了. 在這混亂的國際局勢下, 我來筆記一下 GGUF. 它不是 “GG, U Failed" 的縮寫! 而是全名 GPT-Generated Unified Format, 一個為了 LLM 推理而設計出的檔案格式.

一般我們去 Huggingface 下載一個模型, 它會可能是 .bin 檔, safetensor 檔, 或是 ONNX 檔. 以 safetensor 而言, 裡面放的是模型未量化過的模型權重. 至於 model 的架構 (graph 長相), 會另外寫在 config.json 裡面. 雖然這個檔案名稱也通用於很多其他的地方, 這裡特別是指用來描述模型的 config.json.

例如 Gemma-2-2B 的 config.json [1] :

{
  "architectures": [
    "Gemma2ForCausalLM"
  ],
  "attention_bias": false,
  "attention_dropout": 0.0,
  "attn_logit_softcapping": 50.0,
  "bos_token_id": 2,
  "cache_implementation": "hybrid",
  "eos_token_id": 1,
  "final_logit_softcapping": 30.0,
  "head_dim": 256,
  "hidden_act": "gelu_pytorch_tanh",
  "hidden_activation": "gelu_pytorch_tanh",
  "hidden_size": 2304,
  "initializer_range": 0.02,
  "intermediate_size": 9216,
  "max_position_embeddings": 8192,
  "model_type": "gemma2",
  "num_attention_heads": 8,
  "num_hidden_layers": 26,
  "num_key_value_heads": 4,
  "pad_token_id": 0,
  "query_pre_attn_scalar": 256,
  "rms_norm_eps": 1e-06,
  "rope_theta": 10000.0,
  "sliding_window": 4096,
  "torch_dtype": "float32",
  "transformers_version": "4.42.4",
  "use_cache": true,
  "vocab_size": 256000
}

使用 GGUF 就不用單獨的 config.json 了, 但是生成 GGUF 的時候還是需要. 所以從Huggingface 下載 GGUF 時看到 config.json 也不用太驚訝. 總之, GGUF 檔案裡面除了放模型的權重, 還放了超參數和詞彙表. 所謂超參數就是模型訓練中需要手動設定、無法透過模型訓練的參數 – 這些就是從 config.json 抄過來的.

至於詞彙表 (Vacabulary) 裡面就是放所有 toekn 的字典. 它有一點大, 但不會太大. 假設u有個模型認得 128K 個 token (大約是 GPT-4 的用量), 每個 token 長度 4 bytes, 算起來才 5.1MB, 比起模型權重動輒就是幾 GB, 真的也不差多哪一點.

GGUF 把這些都打包起來, 使用時就不用東市買駿馬,西市買鞍韉,南市買轡頭,北市買長鞭. 假設要量化參數, 下個指令轉出新的 .gguf 檔就好. 包括混合精度也能做到. 更不會改了參數檔, 結果 config.json 沒改到. 我們常常會遇到這個 bug.

雖然 GGUF 把相關資料都包在一起, 但它執行起來並不像個肥宅, 甚至它更省記憶體. 如果我們使用 safetensor, 因為那一整包都是權重, 我們無法知道哪一個 byte 是屬於哪一層的參數, 所以通常整包都得放進記憶體. 像是 Llama 3 70B 量化過也需要用 30GB 記憶體.

反觀 GGUF 自己就有完整的訊息, 它可以把檔案中的模型 memory mapping 到 virtual memory 裡面, run 到哪個 chunk, CPU 或 GPU 直接看 index 載入哪部分的參數即可, 號稱可以作到 zero copy. 以前述 Llama 3 70B 模型為例, 可能只需要 500MB~2GB 的記憶體就夠了. 據說在一台 16GB 的筆電上也能運行.

另外, safetensor 檔案裡面只放權重, 還大剌剌地以 safe 為名. GGUF 包了更多東西進去, 那還能保證安全嗎? 答案也是肯定的. 它對格式和 API 都有所規範, 並內建 SHA-256 校驗, 所以不至於輕易地被駭客埋 code 進去, 並且被當作 Python 執行. 至於 pickle (.pt) 或是 .bin 檔案則是有這方面風險.

最後談 GGUF 的缺點. 首先是它只支援 LLM, 不像 ONNX 適用於所有深度學習 (Yolo 等等)、safetensor 更適用於儲存所有 tensor. 其次是背後沒有大人認養, 所以 toolchain 比較不足. 最後的主要缺點是 – 它對 CPU 優化較好, 同一個 model 用 ONNX 表示的話, 在 GPU 上會跑得更快.

最後叫 Monica 生成一個簡單的 GGUF 檔案範本, 這樣比較好理解.

[檔案頭 Metadata]
general.name = "MiniModel-v1"
general.description = "示範用微型語言模型"
general.file_type = 1 (FP32)
general.architecture = "Transformer"
general.quantization_version = 2

[tensors]
1. token_embedding.weight
   - shape: [5, 3]  # 5個token,每個token向量大小為3
   - data: [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6], ...]

2. layer.0.attention.wq.weight
   - shape: [3, 2]  # 3維輸入 → 2維輸出
   - data: [[0.7, 0.8], [0.9, 1.0], [1.1, 1.2]]

3. layer.0.ffn.w1.weight
   - shape: [2, 4]
   - data: [[...], ...]

實際上 head -c 1000 granite-7b-lab-Q4_K_M.gguf | xxd -c 32, 可以看到檔案前面有幾個文字可讀, 後面 tensor 就都是 binary. 如果仔細看下圖, 會看到裡面還看到 ggml 的字樣, 那是 GGUF 的前代格式. 主要是差在舊版沒有 chunked index 結構, 不方便快速抓出某一層參數, 以及沒有 SHA 保護機制.

[REF]

  1. https://blog.csdn.net/shizheng_Li/article/details/144866526

DataLoader 和 Iterator 的差異

在 trace AI 課程中的訓練代碼時, 看到 iterator 和 DataLoader 同場出現時有點暈, 我想說這兩個做的事情不是一樣嗎? 所以花點時間將這個疑惑釐清.

先講結論: Iterator 是 design pattern, DataLoader 是 PyTorch 的 class.

Iterator 做為 design pattern 的好處是: 不用管 dataset 真正長什麼樣子, 每次都都抓出一包 data, 還用不到的先不抓, 等下次抓, 以節省記憶體.

那一包要抓多大呢? 透過 DataLoader 這個 class 取得 dataloader 這個 object.

然後 data_iterator 是個 iterator, iter() 把 dataloader 轉換成可迭代的 object.

first_batch 的 type 取決 Dataset, 主要就是一包 data. 而第一次呼叫 next() 拿到的是第一包, 而不是第二包.

from torch.utils.data import DataLoader, Dataset
...
dataloader = DataLoader(dataset, batch_size=32, shuffle=True)
....
data_iterator = iter(dataloader)
first_batch = next(data_iterator)

在 Tensorflow 中, 沒有 DataLoader, 但有 tf.data.Dataset. 效果也是一次抓一包.

import tensorflow as tf

# Create a basic dataset
dataset = tf.data.Dataset.range(10)

# Apply transformations (e.g., batch, shuffle)
batched_dataset = dataset.batch(2)

# Iterate through the dataset
for batch in batched_dataset:
    print(batch)

利率公式小整理

自從川普 2.0 之後, 讓我想要惡補一下財經這個領域. 回想起當時亂花的錢, 流失的歲月和破碎的記憶. 先來複習十六年前讀過的東西好了.

[常用縮寫]

  • T = numbers of annuity payments
  • r = the interest rate
  • g = growing rate
  • Po = Present value of annuity
  • PMT = Payment
  • A = annuity payment
  • PT = Expected future value of investment
  • n = number of compounding periods
  • FV = Future value of an annuity
  • PV = Present value of cash flows

[基本公式]

PT = Po(1 + r)T

Po = PT(1 + r)-T

  • if Perpetuities, T ->∞,

Po = (A/r) (1-(1 + r)-T) = A/r

[縮寫公式]

Annual Compound Factor = ACF(r, n) = [(1+r)n −1]/r

Annual Discount Factor = ADF(r, n) = (1−(1+r)-n)/r

[分期付款]

貸款金額的複利本利和 = 每月還款金額的 ACF,

假設貸款 100 萬, 每個月還 1 萬, 一共還 10 年, 共 120 期.

理解為現在的 100 萬, 放著會生利息, 最後得到 Fv. 由貸款人每個月 PMT 的 ACF 償還.

100 * (1+r)120 = ACF(r, 120) = [(1+r)120 −1]/r

100 = [1-(1+r)-120 ]/r

PV = PMT * [1-(1+r)-n ]/r

[不斷配息的股票]

有個股票, 每年無止盡地配息 100 元, 堪比永動機, 股價為何不是無限大?

Perpetuity is a series of equal payments of a fixed amount for an
infinite number of periods.

  • if Perpetuities, T ->∞,

Po = (A/r) (1-(1 + r)-T) = A/r

假設機會成本為 5%, 該股票只值 2,000 元.

[利息成長的股票] (Growing Anuity)

假設這個股票不只是每年配息 A, 利率 r, 還會以 g 的比例成長, 那價值應該更高了吧!

第一年, 配息 A

第二年, 配息成長為 A(1+g)

第 n 年, 配息成長為 A(1+g)n-1, 折現為 A(1+g)n-1 / (1+r)n

將無限多年的折現現金流加起來:

PV = ∑t=1A(1+g)t−1​ / (1+r)

把 1+r 和 1+g 的指數弄成一樣 PV = A / (1+r) * ∑t=1 ​((1+g) / (1+r))t-1

因為 ∑t=0x=1 / (1−x) 當 ​∣x∣<1. 也就是末項趨近於 0, 首項為 1 的等比級數公式特例.

其中, 乘號左邊為 A / (1+r), 右邊為 1 / (1-x), 其中 x = (1+g)/ (1+r)

右邊 = 1 /( (1+r-1-g)/ (1+r)) = 1 /( (r-g)/ (1+r)) = (1+r) / ( r-g)

PV = (A / (1+r)) * (1+r)/ (r-g) = A / (r-g)

當然 r > g 的時候本公式都符合直覺. 忘掉一切, 只記得 A / (r-g) 就好.

[利息成長的縮寫公式]

Annual Discount Factor = ADF(r, n, g) = 上面沒投機化簡的 PV = A / (1+r) * ∑t=1n ​((1+g)/ (1+r))t-1 ,

老老實實地按等比級數求和展開, 乘號右邊 ∑t=1n ​((1+g)/ (1+r))t-1 = 1-(1+g)/ (1+r))n / ( 1- ​((1+g)/ (1+r)))

PV = A / (r-g) *(1- ((1+r)/(1+g)) n-1)

同樣的思路可以推導出, ACF (r.n.g) = A/(r-g) * ((1+r)n – (1+g)n)

以前除了手算, 考試可以用財務計算機. 我手上的 TI BA II Plus 已經沒電了, 但沒有換電池的必要, 現在有 Excel 處理這些東西, 更可以無腦問 AI. 不過呢, 直覺是慢慢培養起來的, 一直問 AI, 自己沒辦法產生敏銳度~~~

Pytorch 轉 ONNX 小筆記

基本上這個轉換有兩大類方法, 一類是用官方 tool 去轉, 另一類就是寫個 Python 小程式去做. 原先我都是嘗試後面這路, 但要顧慮的東西很多, 一下修語法, 一下 memory 爆掉, 而是默默出錯時也會轉出 model, 要測試過才知道它的智力有沒有受損?搞得滿累的.

當我再次卡在下面這個檔案限制時, 我就決定換方法了 (悔不當初).

RuntimeError: The serialized model is larger than the 2GiB limit imposed by the protobuf library. Therefore the output file must be a file path, so that the ONNX external data can be written to the same directory. Please specify the output file name.

官方做法其實很簡單, 唯一要顧慮的是 onnx, onnxruntime, onnxruntime_genai 這三個軟體有沒有跟系統衝突? 有沒有跟 NPU tool 衝突 ? 這些搞定就可以了. 用 CPU 也不會轉太久. 這次的障礙是 DeepSeek 跟我講錯指令, 下面這行跑起來找不到 builder.

python -m onnxruntime_genai.builder --model microsoft/phi-2 --precision fp16

我去 onnxruntime 安裝的目錄下找, 確實也沒有對應的程式, 所以我把 Monica 預設的 DeepSeek R1 切到提供第二個意見的 Claude Sonnet V3.7, 它就指出 DeepSeek 的錯誤了, 哈!正確指令如下:

python -m onnxruntime_genai.models.builder \
  --model microsoft/phi-2 \
  --precision fp32 \
  --output ./phi-2-onnx \
  --execution_provider cpu \
  --cache_dir ~/.cache/huggingface \
  --extra_options trust_remote_code=True

轉完之後, 當然要測試一下有沒有問題? 如果發現它答非所問, 應該就是轉錯了. 然而, 我發現 DeepSeek R1 寫的測試程式還是遜了一點, 所以我又讓 Claude 重寫一次.

import numpy as np
import onnxruntime as ort
from transformers import AutoTokenizer
from typing import List, Dict, Optional, Tuple
import time

class Phi2ONNXGenerator:
    def __init__(self, model_path: str, tokenizer_path: str = "microsoft/phi-2"):
        """初始化 Phi-2 ONNX 生成器"""
        # 載入分詞器
        self.tokenizer = AutoTokenizer.from_pretrained(tokenizer_path)
        self.tokenizer.pad_token = self.tokenizer.eos_token
        
        # 設定 ONNX 執行選項以優化效能
        sess_options = ort.SessionOptions()
        sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
        sess_options.intra_op_num_threads = 4  # 調整為您的 CPU 核心數
        sess_options.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
        
        # 建立推理會話
        self.session = ort.InferenceSession(
            model_path, 
            sess_options=sess_options,
            providers=['CPUExecutionProvider']
        )
        
        # 獲取模型輸入輸出資訊
        self.input_names = [input.name for input in self.session.get_inputs()]
        self.output_names = [output.name for output in self.session.get_outputs()]
        
        # 模型常數
        self.num_layers = 32  # Phi-2 有 32 層注意力層
        self.head_dim = 80    # 每個注意力頭的維度
        self.num_heads = 32   # 注意力頭數量
        
        # 快取字首
        self.key_prefix = 'past_key_values.'
        self.key_suffix = '.key'
        self.value_suffix = '.value'

    def _initialize_kv_cache(self, batch_size: int = 1) -> Dict[str, np.ndarray]:
        """初始化 KV 快取為零張量,使用預分配記憶體"""
        kv_cache = {}
        for i in range(self.num_layers):
            k_name = f'{self.key_prefix}{i}{self.key_suffix}'
            v_name = f'{self.key_prefix}{i}{self.value_suffix}'
            
            # 預分配零張量
            kv_cache[k_name] = np.zeros(
                (batch_size, self.num_heads, 0, self.head_dim), dtype=np.float32
            )
            kv_cache[v_name] = np.zeros(
                (batch_size, self.num_heads, 0, self.head_dim), dtype=np.float32
            )
        return kv_cache

    def _prepare_inputs(self, 
                        input_ids: np.ndarray, 
                        attention_mask: np.ndarray, 
                        kv_cache: Optional[Dict[str, np.ndarray]] = None) -> Dict[str, np.ndarray]:
        """準備模型輸入"""
        inputs = {
            'input_ids': input_ids,
            'attention_mask': attention_mask
        }
        
        # 加入 KV 快取(如果提供)
        if kv_cache:
            inputs.update(kv_cache)
            
        return inputs

    def _update_kv_cache(self, outputs, start_idx: int = 1) -> Dict[str, np.ndarray]:
        """從模型輸出更新 KV 快取"""
        kv_cache = {}
        for i in range(self.num_layers):
            k_idx = start_idx + 2*i
            v_idx = start_idx + 2*i + 1
            
            k_name = f'{self.key_prefix}{i}{self.key_suffix}'
            v_name = f'{self.key_prefix}{i}{self.value_suffix}'
            
            kv_cache[k_name] = outputs[k_idx]
            kv_cache[v_name] = outputs[v_idx]
            
        return kv_cache

    def generate(self, 
                prompt: str, 
                max_new_tokens: int = 100,
                temperature: float = 1.0,
                top_k: int = 50,
                top_p: float = 0.9,
                do_sample: bool = True) -> str:
        """生成文本"""
        start_time = time.time()
        
        # 編碼輸入文本
        encoded_input = self.tokenizer(prompt, return_tensors="np")
        input_ids = encoded_input['input_ids'].astype(np.int64)
        attention_mask = encoded_input['attention_mask'].astype(np.int64)
        
        # 初始化 KV 快取
        kv_cache = self._initialize_kv_cache()
        
        # 初始化輸入
        onnx_inputs = self._prepare_inputs(input_ids, attention_mask, kv_cache)
        
        # 保存原始提示的 token IDs
        prompt_ids = input_ids[0].tolist()
        generated_ids = []
        
        # 逐步生成文本
        for i in range(max_new_tokens):
            # 執行推理
            outputs = self.session.run(None, onnx_inputs)
            
            # 獲取 logits
            logits = outputs[0][:, -1, :]  # [batch, vocab_size]
            
            # 應用溫度
            if temperature > 0:
                logits = logits / temperature
            
            # 選擇下一個 token
            if do_sample:
                # Top-K 過濾
                if top_k > 0:
                    indices_to_remove = logits < np.partition(logits, -top_k, axis=-1)[..., -top_k:][..., :1]
                    logits[indices_to_remove] = -float('Inf')
                
                # Top-p (nucleus) 採樣
                if top_p < 1.0:
                    sorted_logits = np.sort(logits, axis=-1)[:, ::-1]
                    cumulative_probs = np.cumsum(np.exp(sorted_logits) / np.sum(np.exp(sorted_logits), axis=-1, keepdims=True), axis=-1)
                    
                    sorted_indices_to_remove = cumulative_probs > top_p
                    sorted_indices_to_remove[:, 1:] = sorted_indices_to_remove[:, :-1].copy()
                    sorted_indices_to_remove[:, 0] = False
                    
                    # 將索引轉換回原始順序
                    indices_to_remove = np.zeros_like(logits, dtype=bool)
                    for batch_idx in range(logits.shape[0]):
                        indices_to_remove[batch_idx, np.argsort(-logits[batch_idx])[sorted_indices_to_remove[batch_idx]]] = True
                    
                    logits[indices_to_remove] = -float('Inf')
                
                # 計算概率並採樣
                probs = np.exp(logits) / np.sum(np.exp(logits), axis=-1, keepdims=True)
                next_token = np.random.choice(probs.shape[-1], p=probs[0])
            else:
                # 貪婪解碼
                next_token = np.argmax(logits, axis=-1)[0]
            
            # 終止條件
            if next_token == self.tokenizer.eos_token_id:
                break
                
            # 更新生成的 token 列表
            generated_ids.append(int(next_token))
            
            # 更新輸入
            onnx_inputs['input_ids'] = np.array([[next_token]], dtype=np.int64)
            
            # 更新注意力遮罩
            new_attention_mask = np.ones((1, attention_mask.shape[1] + 1), dtype=np.int64)
            new_attention_mask[0, :attention_mask.shape[1]] = attention_mask[0]
            attention_mask = new_attention_mask
            onnx_inputs['attention_mask'] = attention_mask
            
            # 更新 KV 快取
            kv_cache = self._update_kv_cache(outputs)
            onnx_inputs.update(kv_cache)
        
        # 計算生成時間
        generation_time = time.time() - start_time
        tokens_per_second = len(generated_ids) / generation_time if generation_time > 0 else 0
        
        # 解碼並返回生成的文本
        result = self.tokenizer.decode(generated_ids, skip_special_tokens=True)
        
        print(f"生成了 {len(generated_ids)} 個 tokens,耗時 {generation_time:.2f} 秒 ({tokens_per_second:.2f} tokens/秒)")
        
        return result

# 使用範例
if __name__ == "__main__":
    # 初始化生成器
    generator = Phi2ONNXGenerator(
        model_path='./phi-2-onnx/model.onnx',
        tokenizer_path="microsoft/phi-2"
    )
    
    # 生成文本
    prompt = "find all prime numbers below 120"
    result = generator.generate(
        prompt=prompt,
        max_new_tokens=200,
        temperature=0.7,
        top_p=0.9,
        do_sample=True
    )
    
    print(f"\n提示:\n{prompt}")
    print(f"\n生成結果:\n{result}")

DeepSeek R1 給的 inference 程式會寫出大致正確但有錯誤的程式 – 邏輯正確, 但引用函數未定義. 我以為 PHI-2 的極限就是這樣了. 想不到 Claude inference 程式寫得好, 答案竟然也跟著好很多 (雖然還有錯)! 在同樣的 model 下也會有顯著的差異, 令我太意外了.

Claude V3.7 Inference 產生的答案:

import numpy as np

def find_primes(n):
    primes = np.arange(2, n)
    for i in range(2, int(np.sqrt(n))+1):
        primes = primes[primes%i!= 0]
    return primes

print(find_primes(120))

DeepSeek-R1 Inference 產生的答案:

import numpy as np

# Define the upper limit
upper_limit = 120

# Create an array of numbers from 2 to the upper limit
numbers = np.arange(2, upper_limit)

# Use the isprime function to find all prime numbers
primes = numbers[np.vectorize(isprime)(numbers)]

print(primes)