Modele językowe (LLM Basics) – Lab 2

Laboratorium 01B · generowanie

Jak model wybiera kolejny token? Od ocen do prawdopodobieństw

Model nie zwraca od razu zdania. W każdym kroku produkuje wyniki dla całego słownika, a algorytm dekodowania wybiera z nich kolejny token. Zobaczymy liczby stojące za temperaturą, top-k i top-p, zamiast traktować te parametry jak pokrętła kreatywności.


Czego nauczysz się w tej części?

  • odróżnić logit od prawdopodobieństwa
  • obliczyć softmax dla małego wektora
  • wyjaśnić wpływ temperatury na rozkład
  • odróżnić top-k od nucleus sampling
  • zastosować greedy decoding i sampling
  • zmierzyć zmienność wielu odpowiedzi
  • nie zmieniać wielu parametrów jednocześnie bez planu
  • wyjaśnić, dlaczego temperatura zero nie zawsze gwarantuje identyczny tekst

Nie musisz jeszcze: implementować pełnej metody generate(), beam search ani speculative decoding. Masz rozumieć jeden krok wyboru tokenu i umieć zaprojektować uczciwy eksperyment.

Jak będziemy pracować?

  • Krok 1od logitów do prawdopodobieństw
  • Krok 2temperatura i entropia rozkładu
  • Krok 3top-k oraz top-p na pięciu tokenach
  • Krok 4seria wywołań lokalnego modelu
  • Krok 5analiza powtarzalności, długości i czasu
  • Krok 6checkpoint
Zanim zaczniesz

Zapisz pięć kandydatów na następny token i przypisz im przykładowe logity. Wskaż token wybrany przez greedy decoding, a następnie wyjaśnij własnymi słowami, dlaczego logit 2.0 nie oznacza prawdopodobieństwa 200%.

W eksperymencie zmieniaj tylko jeden parametr naraz. Zachowaj ten sam model i prompt, aby nie pomylić wpływu dekodowania z różnicą możliwości modeli.


Logit to wynik, nie prawdopodobieństwo

Ostatnia warstwa modelu przypisuje każdemu możliwemu następnemu tokenowi liczbę nazywaną logitem. Wektor wszystkich logitów oznaczamy przez z, a logit tokenu o indeksie i przez zᵢ. Logity mogą być dodatnie lub ujemne i nie muszą sumować się do jedności. Funkcja softmax przekształca cały wektor logitów w rozkład prawdopodobieństwa.

Odjęcie tego samego maksimum od wszystkich logitów nie zmienia wyniku matematycznego, ale ogranicza ryzyko przepełnienia numerycznego. Po zastosowaniu softmax prawdopodobieństwa są nieujemne i sumują się do jedności.

Policz jeden krok generacji

import numpy as np

tokens = np.array([" kot", " pies", " dom", ".", " samochód"])
logits = np.array([2.4, 1.7, 0.2, 1.1, -0.5], dtype=float)

def softmax(values: np.ndarray) -> np.ndarray:
    shifted = values - values.max()
    exps = np.exp(shifted)
    return exps / exps.sum()

probs = softmax(logits)
for token, logit, probability in zip(tokens, logits, probs):
    print(f"{token!r:12} logit={logit:5.2f}  p={probability:6.3f}")

print("suma:", probs.sum())
print("greedy:", tokens[np.argmax(probs)])

Dodaj 100 do każdego logitu. Czy prawdopodobieństwa się zmieniły? Następnie pomnóż wszystkie logity przez 2. Czy rozkład stał się bardziej płaski, czy bardziej skoncentrowany?

Rozwiązanie kontrolne

Dodanie tej samej stałej do wszystkich logitów nie zmienia wyniku softmax. Pomnożenie logitów przez 2 odpowiada zastosowaniu temperatury T = 0,5: różnice między nimi rosną, a rozkład staje się bardziej skoncentrowany na najwyżej ocenianych tokenach.

Temperatura skaluje różnice między logitami

Przy T = 1 rozkład pozostaje bez zmian. Gdy 0 < T < 1, staje się bardziej skoncentrowany; gdy T > 1, spłaszcza się. Jeśli losujemy następny token, temperatura wpływa na szansę wylosowania poszczególnych kandydatów. Nie dodaje modelowi wiedzy ani nowych możliwości. Przy wyborze zachłannym, czyli zawsze najwyższego prawdopodobieństwa, dodatnia temperatura nie zmienia wybranego tokenu.

def probabilities_at_temperature(logits: np.ndarray, temperature: float) -> np.ndarray:
    if temperature <= 0:
        raise ValueError("Temperatura musi być dodatnia; wybór greedy oblicz osobno.")
    return softmax(logits / temperature)

for temperature in [0.2, 0.7, 1.0, 1.5]:
    probs = probabilities_at_temperature(logits, temperature)
    entropy = -float(np.sum(probs * np.log(probs + 1e-12)))
    print(temperature, np.round(probs, 3), "entropia=", round(entropy, 3))

W interfejsach bibliotek wartość temperature=0 zwykle oznacza tryb zachłanny albo specjalną ścieżkę dostawcy. Do wzoru z / T matematycznie nie podstawiamy T = 0. Wybór zachłanny obliczamy osobno, wskazując token o najwyższym logicie, np. przez np.argmax(logits).

Wybór kolejnego tokenu: greedy, top-k i top-p

MetodaZasadaWażna konsekwencja
greedywybierz token o największym prawdopodobieństwiebrak losowania; najlepszy wybór w jednym kroku nie musi prowadzić do najbardziej prawdopodobnej całej sekwencji
top-klosuj spośród k tokenów o największym prawdopodobieństwieliczba kandydatów jest stała
top-pposortuj tokeny malejąco i zachowaj najmniejszą grupę, której łączne prawdopodobieństwo wynosi co najmniej pliczba kandydatów zależy od rozkładu

Token, którego dodanie po raz pierwszy osiąga lub przekracza próg p, pozostaje w grupie kandydatów. Następnie prawdopodobieństwa zachowanych tokenów ponownie normalizujemy. Jeśli stosujemy oba filtry, w tym przykładzie najpierw działa top-k, a top-p jest obliczane na rozkładzie pozostałym po top-k.

def filter_top_k(probs: np.ndarray, top_k: int) -> np.ndarray:
    filtered = probs.astype(float, copy=True)
    order = np.argsort(-filtered, kind="stable")
    keep = np.zeros(len(filtered), dtype=bool)
    keep[order[:top_k]] = True
    filtered[~keep] = 0.0
    filtered /= filtered.sum()
    return filtered


def filter_top_p(probs: np.ndarray, top_p: float = 1.0) -> np.ndarray:
    filtered = probs.astype(float, copy=True)
    order = np.argsort(-filtered, kind="stable")
    cumulative = np.cumsum(filtered[order])

    # Pierwszy token, po którym łączna masa osiąga co najmniej top_p.
    last = np.searchsorted(cumulative, top_p, side="left")
    keep = np.zeros(len(filtered), dtype=bool)
    keep[order[:last + 1]] = True
    filtered[~keep] = 0.0
    filtered /= filtered.sum()
    return filtered


def filter_top_k_top_p(
        probs: np.ndarray,
        top_k: int | None = None,
        top_p: float = 1.0,
) -> np.ndarray:
    if probs.ndim != 1 or len(probs) == 0:
        raise ValueError("Oczekiwano niepustego wektora prawdopodobieństw.")
    if np.any(probs < 0) or not np.isclose(probs.sum(), 1.0):
        raise ValueError("Prawdopodobieństwa muszą sumować się do 1.")
    if top_k is not None and not 1 <= top_k <= len(probs):
        raise ValueError("top_k musi być w zakresie od 1 do liczby tokenów.")
    if not 0 < top_p <= 1:
        raise ValueError("top_p musi należeć do przedziału (0, 1].")

    filtered = probs.astype(float, copy=True)

    if top_k is not None:
        filtered = filter_top_k(filtered, top_k)

    if top_p < 1.0:
        filtered = filter_top_p(filtered, top_p)

    return filtered


rng = np.random.default_rng(42)
base = probabilities_at_temperature(logits, 0.8)

print("top-k:", np.round(filter_top_k_top_p(base, top_k=3), 3))
print("top-p:", np.round(filter_top_k_top_p(base, top_p=0.9), 3))

filtered = filter_top_k_top_p(base, top_k=3, top_p=0.9)
print("oba:  ", np.round(filtered, 3))

counts = {token: 0 for token in tokens}
for _ in range(1000):
    selected = rng.choice(tokens, p=filtered)
    counts[selected] += 1

print(counts)
Jak losowany jest token po filtrowaniu?

Losowanie nie jest równomierne. Najpierw prawdopodobieństwa zachowanych tokenów są ponownie normalizowane, tak aby sumowały się do 1. Na przykład, jeśli przed filtrowaniem trzy tokeny mają prawdopodobieństwa 0,5, 0,3 i 0,2, to przy top_p = 0,8 zostają dwa pierwsze. Po normalizacji ich szanse wynoszą odpowiednio 0,5 / 0,8 = 0,625 oraz 0,3 / 0,8 = 0,375. Odrzucony token ma szansę równą zero.

Można to sobie wyobrazić jako losowanie liczby z przedziału od 0 do 1: wynik poniżej 0,625 wskazuje pierwszy token, a wynik od 0,625 wzwyż - drugi. W kodzie rng.choice(tokens, p=filtered) wykonuje losowanie zgodnie z prawdopodobieństwami zapisanymi w filtered.

Pętla powtarza losowanie 1000 razy z tego samego rozkładu, aby pokazać, jak często wybierany jest każdy token. Podczas generowania tekstu po każdym wyborze token trafia do kontekstu, a model oblicza nowy rozkład dla kolejnego kroku.

Seria powtórzeń zamiast pojedynczego przykładu

Nie zgaduj wpływu parametrów - zmierz go

Wykonaj po pięć generacji dla tego samego promptu w czterech konfiguracjach. Dla lokalnego backendu z poprzedniej części dodaj parametry temperature, top_p i opcjonalnie seed. Gdy temperatura jest dodatnia, ustaw do_sample=True.

Konfiguracjatemperaturetop_pLiczba powtórzeń
Agreedy-5
B0.31.05
C0.80.95
D1.20.95

Prompt: "Podaj nazwę i jednozdaniowy opis aplikacji pomagającej studentowi planować naukę." Dla każdej konfiguracji zapisz liczbę unikalnych odpowiedzi, długość w tokenach i średni czas.

from collections import Counter
import csv

rows = []
# Uzupełnij pętlę wywołaniami własnego backendu.
# Zapisuj: configuration, run, text, output_tokens, latency_s.

def normalized(text: str) -> str:
    return " ".join(text.lower().split())

counts = Counter(normalized(row["text"]) for row in rows)
print("unikalne odpowiedzi:", len(counts))

with open("lab01b_sampling.csv", "w", newline="", encoding="utf-8") as file:
    writer = csv.DictWriter(file, fieldnames=rows[0].keys())
    writer.writeheader()
    writer.writerows(rows)
Jak ocenić swój eksperyment?

Nie porównuj swojego procentu unikalnych odpowiedzi z jedną wzorcową liczbą - wynik zależy od modelu, sprzętu i wersji bibliotek. Sprawdź natomiast, czy zmieniałeś jedną rzecz naraz, zachowałeś ten sam prompt, wykonałeś powtórzenia i nie utożsamiłeś większej różnorodności z większą poprawnością.

"Prawie deterministycznie" nie znaczy "zawsze identycznie"

Greedy decoding usuwa losowanie z wyboru tokenu po stronie klienta. Wynik nadal może zmienić się po aktualizacji modelu, zmianie wersji biblioteki, użyciu innych jąder obliczeniowych, routingu do innej wersji usługi albo rozstrzygnięciu bardzo zbliżonych wyników numerycznych. Reprodukowalność trzeba więc opisywać wersją modelu, konfiguracją, seedem, sprzętem i datą.

Niektóre modele rozumujące lub interfejsy API nie udostępniają wszystkich parametrów samplingu. Aplikacja powinna wykrywać obsługiwane opcje, a nie zakładać, że każde API ma identyczny kontrakt.

Praca z AI: poproś AI o hipotezę dotyczącą wyniku konfiguracji A–D przed eksperymentem. Po uruchomieniu porównaj hipotezę z danymi i wskaż przynajmniej jeden przypadek, którego sama intuicja nie przewidziała.

Rezultat po tej części

Oddaj lab01b_sampling.csv oraz krótką notatkę zawierającą:

  • cztery konfiguracje i po pięć powtórzeń,
  • liczbę unikalnych wyników i średnią długość odpowiedzi,
  • jeden przykład, w którym bardziej losowa odpowiedź była gorsza albo lepsza,
  • wyjaśnienie różnicy między logitem, prawdopodobieństwem i wybranym tokenem.

Podsumowanie

Co naprawdę warto zapamiętać?

  • logity są nieznormalizowanymi wynikami modelu
  • softmax tworzy rozkład prawdopodobieństwa
  • niska temperatura koncentruje, a wysoka spłaszcza rozkład
  • top-k zachowuje stałą liczbę kandydatów, top-p - zmienną liczbę zależną od masy
  • sampling wpływa na różnorodność, nie dodaje wiedzy
  • powtarzalność należy mierzyć wieloma uruchomieniami

Parametr generacji ma sens tylko wtedy, gdy potrafisz wskazać, jaki element rozkładu zmienia i jak zmierzysz skutek.

Co możesz sprawdzić w dokumentacji lub z pomocą AI?

  • które parametry dekodowania obsługuje konkretny model
  • domyślną konfigurację generation_config
  • sposób ustawiania seeda na danym sprzęcie
  • zaawansowane strategie, takie jak beam search lub speculative decoding

Pytania kontrolne

1. Dlaczego logit może być ujemny, a prawdopodobieństwo nie?
2. Co dzieje się z rozkładem, gdy temperatura maleje?
3. Czym różni się top-p od top-k?
4. Dlaczego po filtrowaniu trzeba ponownie normalizować prawdopodobieństwa?
5. Dlaczego pięć identycznych wyników nie dowodzi pełnej deterministyczności usługi?

Materiały dodatkowe

Materiał przygotowany dla kursu "Modele językowe (LLM Basics)". Stan techniczny: wrzesień 2026. Nazwy modeli i ceny zawsze sprawdzaj w aktualnej dokumentacji dostawcy.


Dodaj komentarz

Twój adres email nie zostanie opublikowany. Wymagane pola są oznaczone *