蒼時弦也
蒼時弦也
資深軟體工程師

從測試 Stable Diffusion 結果反思軟體測試

因為已經非常習慣寫軟體測試在用工程師的方式入門生成式 AI - Stable Diffusion 這篇文章實作的過程中,我開始思考「Generative AI 的產出能被測試嗎?」這樣的問題。

亂數種子

如果有在關注 Stable Diffusion 相關的作品,可能會注意到分享的人會附上一個叫做 Seed(種子)的資訊,只要使用相同的 Prompt(提示)和參數,那麼就可以得到相同的結果。

假設排除 Seed 的參數,其他輸入的數值都是固定的但每次產生出來的結果都會非常不一樣,在這樣的狀況下似乎就變得無法進行測試。然而,如果加入 Seed 的參數,就可以解決掉這樣的問題。

寫這篇文章剛好朋友傳來網路上的教學影片,發生用了相同 Seed 得到不同結果的差異,在 PyTorch 的文件上有說明,理論上只要環境相同應該是能重現的。

實際上這個 Seed 的參數,對大多數的測試框架都可以設定,以 RSpec 為例子,我們可以在跑測試的時候給予 --seed 的選項。

1rspec --seed 1000

這樣一來,所有 Ruby 中使用到像是 rand(1000) 的結果,就會被固定,這是由亂數表這類機制來產生效果的,因此某些對隨機性非常要求的服務(如:遊戲)還會透過時間、玩家編號、伺服器編號等等資訊,隨時調整 Seed 來確保更好的隨機性。

實務上是不推薦使用 --seed 來控制隨機結果的,下一個段落會討論這個問題。

不確定的軟體

從我的角度來看,設計軟體時應該要避免「不確定」的情況發生,像是隨機、當下時間等等,這類資訊通常都應該視為輸入(Input)也就是來自使用者或者環境的資訊。

也許你碰過像是測試隨機失敗,原因是因為「以某個時間點當條件」之類的情境,或者在做遊戲時,因為要有一個隨機的傷害數值,最後放棄驗證這個情況之類的狀況。從我的角度來看,這其實是沒有釐清這類數值的職責。

我們用隨機傷害數值作為例子,以「功能測試」的角度來看,假設最少會有 1 點傷害,同時每一點 STR 都會等比例換算成傷害,我們是可以這樣驗證的。

 1Feature: Normal Attack
 2  Scenario: Player use normal attack and damage enemy
 3    Given there is a player "Aotoki" with these attributes
 4      | attribute | value |
 5      | HP        | 100   |
 6      | STR       | 10    |
 7    And there is a monster "Goblin" with these attributes
 8      | attribute | value |
 9      | HP        | 100   |
10      | STR       | 10    |
11    When "Aotoki" click "Normal Attack" button
12    Then I can see "Goblin is damaged" between 1 to 10

根據邏輯推導出來,我們只需要驗證 MAX(1, RANDOM() * STR) 的條件符合,最後就是 110 這個範圍就可以,實際上是沒有不確定因素的。

在遊戲業中,會用 DPS(Damage Per Second)來評估這類遊戲單一角色的強度來控制平衡,如果把 1 ~ 10 大量的重複統計後,應該會接近某個數值,實際上也就「不那麼隨機」如果無法做到,那麼遊戲的體驗就變得不可控。

假設我們繼續拆分功能變成「單元測試」那麼連隨機也就不會出現在這個狀況中,因為我們應該要能有一組固定的公式,我們可以思考以下兩個方法哪一個是更為合理的。

1# A: Random in method
2def calculate_damage(str)
3  rand(str)
4end
5
6# B: Random in parameter
7def calculate_damage(str, ratio)
8  str * ratio
9end

實際上 B 方案是更合理的,那麼我們的 rand() 方法應該在哪裡被呼叫呢?取得當下日期、隨機數等等「不確定」的部分,都視為輸入的狀況下,就是在 Use Case(使用案例)的情境下處理。

 1class AttackController < ApplicationController
 2  # ...
 3  def normal
 4    target_id = params[:target_id]
 5    ratio = rand()
 6    # ...
 7
 8    amount = attack_service.calculate_damage(player.str, ratio)
 9    target.damanged(amount)
10    target.save!
11  end
12end

像這樣子我們盡可能把「不確定」的資訊往使用者端靠近就可以讓單元測試不需要去處理「不確定」的狀況,這也讓我們的「商業邏輯」是有邏輯的,畢竟我們不會預期一套系統的結果永遠是「隨機」的,即使是遊戲中的所有數值也都能夠「統計」出來。

那麼,為什麼 Stable Diffusion 會提供一個 Seed 的選項,是不是把「隨機」這件事情從 Use Case 繼續往外推,作為一個輸入的選項讓使用者來決定,來增加可控的程度。

脈絡的影響

解決了隨機的問題後,我們來看 Prompt 的效果是受到 Context(脈絡)有的影響。我們能產生怎樣的圖像,是由 Model(模型)加上 Prompt 來決定的,因此使用不同的 Model 來生成圖像,能發揮作用的 Prompt 也不盡相同,這就跟我們在描述需求一樣,越完善的脈絡越能被正確測試。

我們可以先來看一下這兩張圖片,我使用了相同的 Prompt 和參數,但是是基於不同的 Model 來生成(下方是加入 Negative Prompt 排除後的效果)

結果差異

差異是肉眼可見的明顯,造成影響的就是 Model 不同,提供給 AI 的 Context 就完全不一樣,那麼後續對相同的 Prompt 得出的結論就會不同,最後得到不一樣的結果。

更多人知道的可能是 ChatGPT 的 AI,實際上目前我們使用的都是叫做 Completion(補完)的機制,常見的使用做法是會先給予一些樣板作為 Context 然後再給予 Prompt 來「補齊缺少的語句」

舉一個有趣的例子是,如果你問 ChatGPT「今天是幾月幾號」他是無法回答你的,因此用「今天有什麼新聞」會無法使用,然而如果改為「假設今天是 2021 年 1 月 1 日,發生了什麼事?」那麼就相對精準的得到當天的一些事件,這是因為我們把「時間點」作為輸入提供進去的關係。

詢問當天日期在 ChatGPT 上來說可能是能回答的,因為 ChatGPT 很可能不是單純的利用模型生成,可能會根據詢問日期推導出「查詢」的意圖,並且呼叫一些輔助工具來解決問題(Meta 提出的語言模型就具備這樣的能力)

這些模型我們可以把它視為 Domain Knowledge 的概念,所以 OpenAI 公司針對 ChatGPT 類型的語言模型,提供了 Fine Turning(微調)的機制,讓我們可以預先加入相關的知識來符合特定情境。

我們以前面戰鬥的例子來看,直接讓 ChatGPT 回答會要求給予更多上下文(Context 的另一種翻譯)然而給予功能測試後,就能根據上下文推斷出可能的結果。

脈絡影響

這其實也是影響我們在撰寫測試的品質的另一個關鍵,我們的軟體是建構在某個特定領域下的,這個產業會有很多預設的 Context 存在,如果沒有正確的獲取這些資訊,我們是很撰寫出正確的測試,會有過多不確定的條件限制在裡面。

回到對 Stable Diffusion 這類生成式 AI 的結果做測試這件事情上,我們會發現測試本身其實並不困難,反而是現在要還原能夠產生相同結果的環境(有 GPU 的測試伺服器,而且使用的模型還要相同)才是最為困難的地方。