我最近在閱讀有關壓縮的內容時,偶然發現了一件很酷的事:壓縮器和大型語言模型(LLMs)在核心上,都在試圖解決完全相同的問題。
在這篇文章中,我將帶大家了解壓縮的基礎知識,以理解它與語言模型之間深刻的關係。這可能會讓你大吃一驚。
有許多方法可以縮減資料。以最小化(minification)為例:它透過將程式碼精簡到機器解析所需的最低限度來運作。人類可讀的變數被縮減為單一字母;空白字元和註解被移除。
透過移除註解、將變數名稱縮短為單一字母,並移除空白字元、大括號和分號,將其最小化至 62 個字元 — 小了 60%。
結果檔案明顯變小了,但你在資料壓縮領域幾乎聽不到關於最小化的討論。這是為什麼呢?
最小化相當直接:它只是丟棄了機器不需要的任何語法。但「真正的」壓縮依賴於冗餘來濃縮資料。
考慮字串「AAAAAAAAABBBBCCDAAADDDDDDDDD」,其中有九個 A、四個 B、兩個 C、一個 D、三個 A,然後是九個 D:這裡有很多冗餘。我們可以透過記錄每個字元依序出現的總次數來將其編碼為一個較短的字串:
原始字串:9 個 A、4 個 B、2 個 C、1 個 D、3 個 A、9 個 D — 共 28 個字元,224 位元。
將每個連續出現的字元及其重複次數替換後,得到 A9B4C2D1A3D9 — 共 12 個字元,96 位元,小了 57%。
使用標準的 8 位元 ASCII 編碼,我們的原始字串需要 224 位元,而我們的壓縮字串(「A9B4C2D1A3D9」)僅需 96 位元。不錯吧!
上述技術只是其中一種壓縮方法(稱為行程長度編碼),但我們可以做得更好。像 gzip、Brotli 等實際的壓縮器,依賴多種方法來縮減資料。我們來看看。
現代壓縮工具大致有三個「器官」:轉換(transforms)、模型(models)和熵編碼器(entropy coders)。我將這些術語視為清晰且獨立的事物,但界線可能有點模糊,而且它們很少單獨使用。
轉換是讓我們的資料更容易壓縮的預處理步驟。我們之前看到的行程長度編碼就是一個轉換的例子,但值得注意的是,轉換並不總是能縮減資料。有時它們可以用來創造更多冗餘,而冗餘越多,之後就能壓縮得越多。我們在這篇文章中不會專注於轉換,但它們仍然是任何壓縮工具的重要組成部分。
模型根據每個符號的頻率(無論我們用什麼單位來尋找冗餘:字母、數字、標記,甚至是二進位碼)來描述資料的形狀。目前,你可以將模型視為一個將每個符號映射到其機率的表格,但正如我們稍後將看到的,它們可以變得更加複雜。
以下是基於我們之前字串的例子:
原始字串:9 個 A、4 個 B、2 個 C、1 個 D、3 個 A、9 個 D — 共 28 個字元。
按符號計數:12 個 A、10 個 D、4 個 B、2 個 C。
熵編碼器幾乎總是任何壓縮演算法的最後一步,也是產生最終壓縮成品的部分:一個原始位元流(bitstream),它只是一連串沒有檔案格式包裝的裸位元。
我想專注於最後兩個步驟,因為這很重要。我們的資料模型將一組機率交給熵編碼器,以便盡可能有效地編碼你的資料。輸入機率,輸出壓縮後的位元流:
現在,老實說,這仍然有點含糊。熵編碼器到底會用這些機率做什麼?這如何幫助它進行壓縮?
每個熵編碼器都是獨一無二的,它們使用機率壓縮資料的方式差異很大。為了簡單起見,我們現在只專注於一種:算術編碼(arithmetic coding)。我選擇它,是因為它最能說明更好的機率如何帶來更好的壓縮。
如果我告訴你,你可以用一個數字來代表整個資料集?這聽起來很瘋狂嗎?我也是這麼想的,但這正是算術編碼所承諾的。
假設我們要壓縮字串「ABABAAC」。我們可以透過將總計數除以字串的總長度(7)來找到每個符號(字元)的機率:
原始字串:1 個 A、1 個 B、1 個 A、1 個 B、2 個 A、1 個 C — 共 7 個字元。
按符號計數:4 個 A、2 個 B、1 個 C。
我們可以在 0-1 的範圍內表示這些機率。
從 0 到 1 的範圍,劃分為每個符號一個區段,每個區段的寬度與該符號的機率成正比,並按寬度排序:A 佔 0 到 0.571,B 佔 0.571 到 0.857,C 佔 0.857 到 1。
有了這個設定,我們就可以進行實際的壓縮了。
對於我們字串中的每個符號,從「A」開始,我們將範圍縮小以適應該符號的區段。重要的是,我們仍然使用相同的機率來劃分這個新範圍,但它們現在有了新的、更小的範圍。
點擊箭頭編碼每個符號,看看範圍如何隨著時間縮小:
算術編碼的互動式逐步說明,用於字串 A B A B A A C,其中每個符號共享一個機率分佈。它從 0 到 1 的完整範圍開始,沒有編碼任何內容。每一步都會突出顯示下一個符號編碼的切片,然後顯示該切片變成的範圍。最後一步會放大到最終範圍。
一旦我們用完了符號,我們就會得到一個非常小的範圍:[0.38730, 0.38855)。
代表我們整個資料的最終數字可以是該範圍內的任何數字,理想情況下,它應該是需要最少位元的數字。你可以透過一點數學計算出來,但因為我很友善,所以我直接給你答案:0.3876953125。所以讓我們比較一下:我們的原始字串「ABABAAC」的原始 8 位元 ASCII 編碼總共需要 56 位元,而我們的最終數字僅需 10 位元。
所以,我們有了這個神奇的數字,但如何用它來解碼我們的原始訊息呢?繫好安全帶,這看起來像個魔術。
除了我們的神奇數字,我們的解碼器還接收與我們壓縮時相同的機率,以便重建起始範圍 [0, 1)。要解碼我們的原始訊息,它會找出我們的神奇數字落在哪個區段,並記錄該符號。然後它將範圍縮小以適應該區段,並重複整個過程。
算術解碼的互動式逐步說明,用於數字 0.3876953125。符號機率將 0 到 1 的範圍劃分為區段:A 佔 57%,B 佔 29%,C 佔 14%。每一步都會放大當前範圍,顯示數字落在其中 — 該區段即為解碼的符號 — 然後顯示該區段變成的範圍。最後一步解碼最後一個符號,恢復整個字串。
我們現在已經了解了熵編碼器如何使用一組機率來壓縮我們的資料。算術編碼雖然很酷(不只是我這麼覺得,對吧?),但大部分的繁重工作來自於模型。記住:壓縮喜歡冗餘。考慮到這一點,如果我們的符號有更多的重複,會發生什麼事?
這裡有一個新的字串,其中字母 A 佔主導地位,機率為 0.833。
原始字串:10 個 A、1 個 B、1 個 C — 共 12 個字元。
按符號計數:10 個 A、1 個 B、1 個 C。
結果證明,這種傾斜的機率分佈產生了很大的差異。讓我們看看當我們應用算術編碼時,它與我們舊字串的比較:
我們的第一個字串平均壓縮到 1.38 位元/符號,而我們較長的字串壓縮到 0.82 位元/符號。當你的資料越傾斜(即某些符號的機率越高),壓縮比就越好。
這個平均位元/符號是一個非常重要的數字。它被稱為熵(entropy),是壓縮的基石。
「昨天我在市區散步時看到一隻動物。牠是一隻 _____ 。」
你認為需要多少次猜測才能填空?如果是一隻常見的動物,如鳥(bird),你可能第一次就猜對了。但如果答案是熊(bear)呢?那可能需要很多次猜測。
假設這些是可能的答案,以及它們的機率(以分數表示):
知道機率後,我們可以實際計算平均每次猜測正確需要多少次猜測,針對每種動物。
請注意,每種動物的機率是前一種的一半,除了狐狸(fox)和熊(bear)(這些是機率,所以我們的數字需要加起來等於 1)。如果我們按順序猜測每種動物,從最可能的到最不可能的,我們每次都有 50/50 的機會猜對。因此,我們可以確定猜測給定動物(平均而言)所需的猜測次數,使用一個是/否決策樹。我們從最可能的動物開始,然後向下移動:
一個關於動物的是/否決策樹。牠是鳥嗎?如果是,鳥,猜測 1 次。如果不是,牠是松鼠嗎?如果是,松鼠,猜測 2 次。如果不是,牠是貓嗎?如果是,貓,猜測 3 次。如果不是,牠是狐狸嗎?如果是,狐狸,猜測 4 次。如果不是,熊,猜測 4 次。
讓我們回到壓縮。機率較高的符號有助於我們更好地壓縮,我們在決策樹中也看到相同的模式:機率較高的動物需要較少的猜測。如果我們將動物視為符號,並將「是」和「否」替換為 1 和 0,則猜測次數就等於表示每個動物所需的位元數。如果我們記錄到達每個動物所需的 1 和 0,你會發現較常見的動物獲得較短的「編碼字」(codewords,即唯一的位元序列),而較稀有的動物則獲得較長的編碼字。
一個關於動物的是/否樹。每個「是」分支標記為 1,每個「否」分支標記為 0,因此動物的編碼字是到達它的分支標籤。牠是鳥嗎?如果是,鳥,編碼字 1。如果不是,牠是松鼠嗎?如果是,松鼠,編碼字 01。如果不是,牠是貓嗎?如果是,貓,編碼字 001。如果不是,牠是狐狸嗎?如果是,狐狸,編碼字 0001。如果不是,熊,編碼字 0000。
像這樣為符號分配編碼字,實際上是另一種熵編碼器,稱為霍夫曼編碼(Huffman coding),它被用於 gzip 和 Brotli 等流行工具中。霍夫曼方法不是像算術編碼那樣將我們的資料編碼成一個數字,而是創建編碼字來表示每個符號。
但有一個問題:當我們的機率沒有整齊地分成兩半時會發生什麼?如果貓的機率是 0.3973,那麼答案是貓或不是貓的可能性就不再是 50/50 了。樹的每個路徑都是一個整數的「猜測」次數,所以我們被迫四捨五入,而四捨五入意味著為我們不需要的位元付費。我們如何才能知道表示給定符號所需的絕對最少位元數?
結果發現,我們可以透過一點數學來計算這個值:
如果我們代入動物的機率,你會發現我們得到與決策樹中的猜測次數相同的位元數:
如果我們計算所有符號的平均 −log 2 (機率) 的負對數(以 2 為底),這告訴我們它的熵。
關於熵最重要的一點是,它是下限。這是給定資料集可以達到的每符號的最小位元數。它不可能再壓縮了。
但是,如果資料壓縮真的有一個極限,為什麼我們不只使用一個超級壓縮器來處理所有事情呢?嗯,那是因為熵是針對一組機率而言的。如果我們能讓我們的機率分佈更傾斜,我們就能壓縮更多。
到目前為止,我們一直在處理一種非常簡單的模型,它只關心符號的頻率。 count / total_symbols = its probability。
但上下文會極大地影響符號的機率。例如,在整個英語語言中,字母 U 的機率約為 0.028。然而,當前面是 Q 時,這個機率會飆升到約 0.999。
此外,較高的機率會壓縮成較少的位元。我們之前在算術編碼部分已經看到這一點,但現在我們可以透過數學來證明它:
使用單一上下文來確定符號的機率稱為一階模型(order-1 model)。它回答了這個問題:「給定(某些上下文),(符號)的機率是多少?」對於一階模型,你將前一個符號作為上下文,但你可以擴展到二階、三階、四階等等,它們會查看前面的 N 個符號。
但我們如何將其輸入熵編碼器呢?以前我們的模型只是一個符號機率表,但有了上下文,我們突然擁有一整套表格,每個前面的符號一張。那麼我們該怎麼辦?
讓我們看看當我們使用一階模型將算術編碼應用於字串「TO BE OR NOT TO BE」時會發生什麼。請注意,隨著我們編碼的每個符號,我們的新範圍包含一組不同的機率。
算術編碼的互動式逐步說明,用於字串 T O 空格 B E 空格 O R 空格 N O T 空格 T O 空格 B E,其中每個符號的機率取決於前一個符號。它從 0 到 1 的完整範圍開始,沒有編碼任何內容。每一步都會突出顯示下一個符號編碼的切片,然後顯示該切片變成的範圍。最後一步會放大到最終範圍。
好的,但是使用 N 階模型對壓縮的影響有多大?
哇!使用一階模型將我們的壓縮輸出減少了一半以上!顯然,添加上下文給了我們更強的機率。換句話說,它幫助我們預測下一個符號是什麼。
你知道還有什麼擅長預測嗎?
說 LLMs 和壓縮之間存在重疊將是巨大的輕描淡寫。事實上,在 2023 年,Google DeepMind 發布了一篇論文,認為語言建模和壓縮是同一事物的兩種視角。
這可能聽起來很奇怪。畢竟,當你想到使用 LLMs 時,你可能會想到將提示輸入 AI 聊天機器人,然後它會給出一個答案。這怎麼會是壓縮呢?
你可能聽過 LLMs 被描述為「花俏的自動完成」,這基本上是真的。當你向 LLM 提交提示時,這就變成了模型用來返回下一個可能單詞機率的上下文。然後它選擇其中一個選項並將其附加到上下文。重複這個過程。這就是 LLMs 生成文本的方式。
既然我們在這裡,讓我們理清一些術語。在 LLMs 中,它返回的技術上不是「單詞」,而是標記(tokens):代表單詞或單詞一部分的數字。標記是 LLM 用於解析上下文和生成回應的詞彙表。
現在考慮一下:雖然熵編碼器是產生最終原始位元流的部分,但其中沒有任何東西可以調整以獲得更好的結果。它們是固定的、確定性的且無損的。如果你想要更好的壓縮,你需要調整模型,以便我們獲得每個符號更高的機率。換句話說,我們需要一個更好的預測器。而在預測方面,LLMs 基本上已經做到了極致:
將 LLMs 用於壓縮與它們用於生成文本的方式類似,除了我們不選擇下一個單詞。為什麼?因為我們不是在嘗試生成新文本。我們已經知道下一個單詞是什麼了!它的工作原理如下:基於前面的標記(即基於上下文),模型會說,「這些是我認為接下來會出現的標記,以及它們的機率。」然後它會查看實際的下一個符號是什麼。模型分配的任何機率都決定了成本(以位元為單位)。如果模型訓練有素,它認為機率最高的標記將是實際的下一個符號。
當你點擊演示時,請注意總位元數(頂部)如何根據每個編碼標記的機率而增加。同樣,表示每個標記所需的位元數由 −log 2 (機率) 的負對數(以 2 為底)決定。
0.00 位元 + −log 2 ( 0.00 ) 的負對數(以 2 為底)
現在,如果模型訓練不佳,它就會付出代價。例如,如果我們的上下文是「The rain in」,一個訓練不佳的模型可能會給「Bermuda」0.82 的機率,但實際的下一個單詞是「Spain」,它分配的機率是 0.02。記住,較低的機率需要更多的位元,所以模型會因為猜錯而受到懲罰:
我們也可以用算術編碼看到這些差異。記住:當我們編碼每個符號時,我們剩下一個越來越小的範圍。編碼低機率的符號(例如當我們的模型猜錯時)會使我們的範圍變得更小。我們的最終數字需要適合這些範圍內,範圍越小,需要的精度就越高。更高的精度 = 更多的數字 = 更多的位元。
兩個模擬算術編碼圖。好的模型持續編碼一個高機率的切片,因此它的範圍在三個層級上保持寬闊。差的模型持續編碼一個低機率的切片,因此它的範圍縮小成一條細縫。
最終範圍編碼為數字 0.61328125
最終範圍編碼為數字 0.8193759582936763763427734375
話雖如此,即使是今天標準下被認為很糟糕的早期 LLMs,也能實現一些令人印象深刻的壓縮比。以下是使用算術編碼的一階模型與 GPT-2 在壓縮查爾斯·狄更斯名言時的比較:
「這是最好的時代,這是最壞的時代,這是智慧的年代,這是愚蠢的年代,這是信仰的時期,這是懷疑的時期,這是光明之季,這是黑暗之季。」
那麼,如果 LLMs 在壓縮方面如此出色,為什麼我們不隨處使用它們呢?
不幸的是,模型在壓縮方面的表現好壞,並不能給我們完整的圖景。你看,壓縮工具的目標不僅僅是盡可能地縮減資料。它是在給定特定資源限制的情況下,盡可能地縮減資料。
以 HTTP 回應為例:當你的瀏覽器請求網頁時,它會發送一個標頭,如 Accept-Encoding: gzip, br,告訴伺服器它可以解碼哪些壓縮格式(gzip、Brotli 等)。伺服器選擇一種來壓縮回應,然後再發送。
假設伺服器使用 gzip 壓縮其回應。當你的瀏覽器收到此回應時,它會使用一個小型內建模型來將 gzip 壓縮的位元流解碼為 HTML、CSS 和 JavaScript。開銷非常小。如果我們改為使用 LLM 來完成這項工作,瀏覽器和伺服器都需要一份 LLM 的副本,這可能有多 GB 大。對於良好的壓縮來說,這是一個很高的代價,而且我們甚至還沒有運行它。壓縮(和解壓縮)資料將需要大量資源,並導致頁面載入速度下降到無法使用的程度。想像一下:對於每個樣式表、每個腳本、每個 JSON payload,都運行一個 LLM 來壓縮和解壓縮。真噁心。
對於壓縮 HTTP 回應這樣微不足道的任務,LLMs 簡直是小題大作:一旦你考慮到模型的體積,你將會運送數 GB 的資料來節省幾 KB。但即使你試圖壓縮比 LLM 大得多的資料集,所需的計算量也是天文數字,這仍然使其不切實際。
將資料壓縮到其熵,在目前來看,這是一個已解決的問題。算術編碼(arithmetic coding),開發於 1970 年代後期,其結果非常接近極限,如今熵編碼器在速度和記憶體方面競爭,而不是壓縮比。
開放性的問題是如何讓我們的熵變得更小。更好的模型 — 更好的預測器 — 幫助我們降低這個數字。LLMs 在這方面表現出色(撇開開銷不談),但真正有趣的是,它們被訓練來最小化確切的每符號位元數。在 LLMs 中,這稱為交叉熵(cross-entropy),但它是相同的基本公式。因此,雖然在壓縮中熵衡量我們可以縮小多少,但在語言建模中,它是我們為了讓模型更擅長預測而減少的一個數字。如果你想深入研究這個細節,請查看 Chris Olah 的這篇文章。
歸根結底,LLMs 和壓縮演算法都是預測器。它們是相同底層數學的兩種表達。壓縮即預測,LLMs 則是壓縮器。