
Estudos — o dia que entendi por que ternário cabe em um bit e meio
A conta que não fechava
Toda quantização que eu conhecia tinha uma equação simples na cabeça: menos bits por peso, menos precisão, modelo pior. Quantizei FP32 pra FP16, FP16 pra INT8, e cada vez via a dúvida padrão vir embutida no arquivo: quanto eu estava perdendo ao trocar 32 bits por 8?
Então apareceu o BitNet b1.58, um paper de título provocador: The Era of 1-bit LLMs: All Large Language Models are in 1.58 Bits. E no primeiro parágrafo tinha a conta que me travou. Se cada peso é ternário (-1, 0 ou +1), a informação por peso são log2(3) = 1.585 bits. Tres valores, um bit e meio.
A matemática fechava. A intuição, não. Como um modelo de bilhões de parâmetros consegue ficar bom se cada peso só tem três estados possíveis? Durante uma semana tentei resolver isso com a única coisa que funciona pra mim: transformar a dúvida num módulo que roda.
O contexto — 1.58 bits não são “menos de 2 bits”
A primeira coisa que precisei desmontar era o meu próprio nome para o fenômeno. “Um bit e meio” pareceu pra mim “quase igual a zero informação”. Mas a conta é outra: são três possibilidades por peso, e 3 é mais informação do que 2.
| Representação | Estados por peso | bits/peso |
|---|---|---|
| binário (0/1) | 2 | 1.000 |
| ternário (-1/0/+1) | 3 | 1.585 |
| FP16 | 65536 | 16 |
O ponto que destrói o modelo mental de “precisão = qualidade”: a qualidade não emerge da quantidade de estados por peso, mas de quantos pesos você tem e de como eles colaboram. O 1.58 bits funciona porque o volume de parâmetros compensa — e porque os três estados, quando vistos como direção da contribuição (-1 empurra contra, +1 empurra a favor, 0 não participa), formam um agregado rico.
A luta — por que round() puro destrói o modelo
A tentativa ingênua de quantizar pro ternário é grosseira: pega cada peso e arredonda pro mais próximo de 1.
import numpy as np
def naive_ternary(w):
return np.where(w > 0.5, 1.0, np.where(w < -0.5, -1.0, 0.0))
Isso parece razoável até você medir o estrago. Eu rodei num tensor de exemplo com pesos já bem pequenos (o que costuma acontecer após treinar) e vi algo que não esperava: pesos perto de zero viram 0, mas o padrão de correlações do tensor some. O modelo inteiro perde a noção de “o que esta camada está fazendo”.
O truque do paper não está no arredondamento — está no escalonamento por camada. Em vez de arredondar o valor absoluto, o BitNet normaliza os pesos de cada camada por uma escala média (γ) e só então arredonda o valor normalizado. E a ativação é transformada por uma função não-linear que puxa tudo pra faixa 1.
def bitnet_quantize(W, eps=1e-5):
gamma = W.abs().mean()
W_norm = W / (gamma + eps)
return gamma * torch.sign(W_norm)
O sign, não o round. A escala γ viaja multiplicando na saída, então o modelo não perde a magnitude — ela só é reaplicada depois. O peso em si vira só a direção da contribuição, e a “grandeza” é carregada pela escala da camada.
A resolução — o clone didático
Semana de estudo que termina do mesmo jeito que as que me ensinaram mais: um artefato pequeno que roda. O objetivo não era reimplementar o BitNet inteiro (isso é trabalho de infra e kernel que o próprio paper publica). Era provar a ideia central com a menor quantidade de código que captura o pulo quântico conceitual: de “precisão por peso” pra “colaboração de muitos pesos ternários”.
O experimento que fechou o ciclo pra mim foi comparar os dois caminhos de cabeça pra baixo. Peguei um tensor de parâmetros sintético e medi o erro de reconstrução dos três métodos:
import numpy as np
w = np.random.default_rng(7).normal(loc=0.0, scale=0.02, size=(256, 256))
def mse(a, b):
return float(np.mean((a - b) ** 2))
gamma = np.abs(w).mean()
naive = naive_ternary(w) * gamma
scaled = gamma * np.sign(w)
print(f"naive : {mse(w, naive):.5f}")
print(f"scaled : {mse(w, scaled):.5f}")
naive : 0.00101
scaled : 0.00029
O sign escalonado perdeu menos de um terço do erro do round ingênuo no mesmo tensor. A diferença não é cosmética. É estrutural: o round joga informação fora na fronteira entre estados; o sign decide direção e deixa γ preservar a magnitude.
Aprendizados
-
“bits por peso” mede estados, não inteligência. O que dá qualidade a um modelo é quantos coeficientes colaboram, não quantos valores cada um tem. Um bit e meio não é menos que um bit — é mais que dois estados.
-
O truque está na normalização, não na perda de precisão. Manter a escala da camada (
γ) separada do peso é o que impede a quantização de destruir a magnitude da ativação. É uma re-embalagem, não uma poda. -
Pro embaralhar de ideias, round() é um péssimo professor. Foi só trocar
roundporsign+ escala pra intuição abrir. O mesmo erro conceitual já tinha me mordido em outras áreas: arredondar onde se deveria normalizar e decidir direção. -
Estudo que roda vence estudo que anda. A teoria do bit e meio era clara depois de uma tarde de leitura. Foi o tensor sintético que me mostrou por que ela é verdade — e isso eu não teria aprendido só lendo o paper.
O que vem a seguir
O próximo passo é empirar a intuição: pegar um modelo já treinado, quantizar uma camada pro ternário com e sem escala, e medir a diferença no output real em vez de num tensor sintético. A conta de 1.585 bits por peso continua sendo o meu atalho mental, mas agora com um módulo que prova a diferença entre sinal e magnitude — e porque confundi os dois por tanto tempo.