Egy rendelésbecslő modellben a normalizáló log-likelihood tagot kell kiszámítanunk. A bemenet az egyes minták logitjainak vektora, például
ahol minden dimenziótlan valós szám. A megfigyelési egység egyetlen mintapont, az eredmény pedig a skalár
Előzetes becslés: mivel a legnagyobb tag
dominál,
a pontos stabil alak
;
a
csak durva felső korlát. A naiv np.log(np.exp(z).sum())
megoldás azonban inf értéket adhat, mert
már túlcsordul a float64 tartományon. Célunk, hogy ugyanezt
a matematikai képletet stabil alakban számítsuk ki, és a véges kimenetet
ellenőrizzük.
A float64 számábrázolás véges tartományú. A kerekítési
hiba fogalmát már a Határérték mint közelítési
folyamat alfejezetben bevezettük; most a túlcsordulást,
alulcsordulást és kivonási hibát különítjük el.
| Mennyiség | Hozzávetőleges float64 érték |
|---|---|
| Legnagyobb ábrázolható normál szám | |
| Legkisebb pozitív normál szám | |
| Legkisebb pozitív szubnormál szám | |
| Gépi pontosság, |
Overflow (túlcsordulás): ha egy művelet eredménye
nagyobb a legnagyobb ábrázolható számnál, a float64
inf-ként jelöli. Például
,
de
már túlcsordul. A math.exp(710) tipikus
CPython-környezetben már OverflowError-t vált ki (míg a
NumPy np.exp(710) jellemzően inf-et ad); a
pontos viselkedés a művelettől és a könyvtártól függ; a pontos határ
implementációfüggő, ezért ne a 710-re építsünk, hanem a stabil
átírásra.
Underflow (alulcsordulás): ha az eredmény abszolút
értéke kisebb a legkisebb ábrázolható pozitív számnál, az érték
-vá
válik. Például
,
ezért math.exp(-750) a gyakorlatban
.
Ez önmagában nem mindig káros, de ha egy későbbi osztás nevezőjébe
kerül, vagy ha logaritmust kellene rá alkalmazni, akkor hibát okoz.
Cancellation (kivonási hiba): két egymáshoz közeli, hosszú szám kivonásakor a közös vezető jegyek kiejtik egymást, és a megmaradó különbségben a bemenetek kerekítési hibája aránytalanul felnagyítódik. Klasszikus példa:
Nagy -re a két tag majdnem egyenlő; a kivonás elveszti az értékes jegyeket. A racionalizált alak viszont stabil:
A két kifejezés algebrailag azonos, numerikusan azonban nagyon eltérő lehet. Ez a későbbi gyakori tévedés magja.
A hiba szemléltetéséhez használjuk a relatív hibát. Ha a közelítő érték és a referenciaérték, akkor
A relatív hiba dimenziótlan; például ha és , akkor .
A függvényérzékenység azt mutatja, hogy a bemeneti apró kerekítési
hiba mennyire erősödik fel a kimenetben. A
esetén nagy
-nél
a függvény kimenete kicsi, de a két bemeneti tag hibája nagy; a relatív
hiba ezért robban. A stabil alakban már nincs ilyen kivonás, így a
relatív hiba a float64 gépi pontosság közelében marad.
A labor központi eszköze a log-sum-exp rövidítéssel ismert számítás. Az általános képlet vektorra:
Ha a értékek nagyok, az exponenciálisak túlcsordulnak. A stabil alak kiemeli a legnagyobb értéket. Legyen
ekkor minden -re , ezért , és nem lép fel túlcsordulás. A stabil képlet:
A helyesség levezetése:
$$ L = \log\Bigl(e^m \sum_i e^{z_i-m}\Bigr) = \log e^m + \log \sum_i e^{z_i-m} = m + \log \sum_i e^{z_i-m}. $$
A második tag legfeljebb , tehát az eredmény mindig és közé esik.
Kézi kis kontroll. Legyen . Ekkor , az eltérések , így
A logaritmus , tehát
Ezt az eredményt használjuk ellenőrző invariantként: a stabil implementációnak ugyanezt kell adnia tűréssel.
A logsumexp eltolás-invariáns: ha minden elemhez ugyanazt a konstanst adjuk, az eredmény -vel eltolódik, mert
Ez az alapja a max-központosításnak. A skálázás ezzel szemben nem invariáns: ha minden elemet ugyanazzal a skalárral szorzunk, a szerkezet miatt az eredmény általában megváltozik, és nem egyszerű eltolással tér el. A softmax például a skálázásra hőmérséklet-értelemben változik:
ahol dimenziótlan hőmérséklet. A növelése az eloszlást laposabbá, csökkentése csúcsosabbá teszi. Numerikai szempontból a skálázás segíthet az overflow elkerülésében, de megváltoztatja a modell szemantikáját; ezért a kísérleti jegyzőkönyvben rögzíteni kell, hogy milyen skálán dolgoztunk.
Az alábbi kód a Northstar kosárlogitokhoz hasonló méretű nagy
bemeneten hasonlítja össze a két alakot. A np.logaddexp a
NumPy beépített stabil logsumexp-alapművelete.
import math
import numpy as np
# Bemenet: egy mintapont logitvektora, shape (3,), dtype float64.
z_large = np.array([1000.0, 1001.0, 1002.0])
def naive_logsumexp(z):
# Mathematikai képlet, numerikusan törékeny.
return np.log(np.sum(np.exp(z)))
def stable_logsumexp(z):
# Stabil képlet: kiemeljük a maximumot.
m = np.max(z) # skalár
return m + np.log(np.sum(np.exp(z - m)))
for name, fn in [("naiv", naive_logsumexp), ("stabil", stable_logsumexp)]:
value = fn(z_large)
print(f"{name:6s} L = {value!r}, véges: {np.isfinite(value)}")
# Kézi kontroll a kis [10, 11, 12] bemeneten.
z_small = np.array([10.0, 11.0, 12.0])
expected = 12.0 + math.log(math.exp(-2.0) + math.exp(-1.0) + 1.0)
got = stable_logsumexp(z_small)
print(f"kézi várt: {expected:.12f}")
print(f"stabil : {got:.12f}")
print(f"hiba : {abs(got - expected):.3e}")
# NumPy beépített változat ugyanarra a kis bemenetre.
np_check = np.logaddexp.reduce(z_small)
print(f"np.logaddexp.reduce: {np_check:.12f}")A nem futtatott kimenet szemléltető; a várható értékek elméleti alapon:
naiv L = inf, véges: False
stabil L = 1002.4076055676442, véges: True
kézi várt: 12.407605567644
stabil : 12.407605567644
hiba : ~1e-15
np.logaddexp.reduce: 12.407605567644
A naiv kimenet a float64 overflow miatt
inf, ezért a végesérték-ellenőrzés
(np.isfinite) hamis lesz. A stabil változat véges, és a kis
bemeneten a kézi kontrollal egyezik.
A stabil L értéke mindig
intervallumba esik. A fenti nagy bemeneten
és
,
így
;
a kapott
ezen belül van. Ez jó önellenőrzés: ha az eredmény kívül esik az
intervallumon, akkor vagy a max-számítás hibás, vagy az összegzésben
csúszott be inf/nan.
A NumPy np.logaddexp.reduce(z_small) ugyanazt a stabil
számítást valósítja meg; a kis bemeneten a két implementáció közötti
különbség a float64 kerekítéséből adódik, és
alatt marad. Tömbalakok: bemenet (n,), kimenet skalár;
kétdimenziós bemenetnél axis=1 esetén a kimenet
(rows,) lesz, mert az összegzés a tengely mentén
történik.
Értelmezés kézi számolással. Számítsd ki kézzel a stabil logsumexp értékét a bemenetre. Add meg értékét, az eltéréseket, az összeget, a logaritmust és a végső -t. Miért nincs itt sem overflow, sem underflow?
Implementálás saját függvénnyel. Írj
stable_logsumexp(z, axis=None) függvényt, amely egy- és
kétdimenziós np.ndarray bemeneten is működik. A
kétdimenziós esetben axis=1 mentén számolj, és a kimenet
shape-je legyen (rows,). Ellenőrizd a
Z = np.array([[0.0, 1.0], [1000.0, 1001.0]])mátrixszal; a várt kimenet elméletileg
[1.31326169, 1001.31326169] körül alakul. Hasonlítsd össze
a np.logaddexp.reduce(Z, axis=1) eredményével, és írasd ki
a maximális abszolút eltérést.
Hibakeresés cancellation-nel. Az alábbi függvény
nagy x-re hibásan 0.0-t adhat, pedig
matematikailag pozitív értéket várunk:
def log_diff(x):
return np.log(x + 1.0) - np.log(x)log1p használatával. (c) Ellenőrizd x = 1e16
esetén: a naiv 0.0, a stabil pedig körülbelül
1e-16; magyarázd, miért.Megváltozott feltétel: cent és euró. Egy tároló
a kosárértéket EUR-centben adja meg:
basket_cents = np.array([99999995.0, 100000005.0, 100000015.0]).
(a) A naiv softmax np.exp(z) / np.exp(z).sum() miért válik
nan-ná? (b) Skálázd át a bemenetet euróra
z_eur = basket_cents / 100.0, és számítsd ki a softmaxot;
ezután számítsd ki a cent-alapú stabil softmaxot
z_cent - z_cent.max() értékekkel. Hasonlítsd össze a két
eredményt: mit mutat ez a skálázás szemantikai hatásáról? A cél az
eredeti cent-logitok valószínűség-arányainak megőrzése; melyik megoldás
teszi ezt?
Megoldás.
,
tehát
.
Az eltérések
.
Az exponenciálisok:
,
,
.
Összegük
.
A logaritmus
,
így
.
Azért nincs sem overflow, sem underflow, mert minden eltérés
és
közé esik, az exponenciálisak
és
között maradnak, amelyek a float64 normál tartomány
kényelmes belsejében vannak.
Megoldás. Egy lehetséges implementáció:
def stable_logsumexp(z, axis=None):
z = np.asarray(z, dtype=np.float64)
m = np.max(z, axis=axis)
if axis is None:
return m + np.log(np.sum(np.exp(z - m)))
m_expanded = np.expand_dims(m, axis=axis)
return m + np.log(np.sum(np.exp(z - m_expanded), axis=axis))
Z = np.array([[0.0, 1.0], [1000.0, 1001.0]])
print(stable_logsumexp(Z, axis=1))
print(np.logaddexp.reduce(Z, axis=1))
print(np.max(np.abs(stable_logsumexp(Z, axis=1) -
np.logaddexp.reduce(Z, axis=1))))A várt kimenet elméletileg:
[ 1.31326169 1001.31326169]
[ 1.31326169 1001.31326169]
~2e-15
Az axis=1 esetén a kimenet shape (2,), mert
a soronkénti maximum plusz a soronkénti log-összeg vektoros művelet. A
np.logaddexp.reduce ugyanazt a stabil számítást végzi; az
eltérés a gépi kerekítésből adódik, és
nagyságrendű marad.
Megoldás. (a) A hiba neve cancellation vagy
kivonási hiba:
és
nagy
-re
egymáshoz nagyon közeli számok, a float64 pedig a
kivonáskor elveszti a hasznos jegyeket. (b) A stabil átírás:
def log_diff_stabil(x):
return np.log1p(1.0 / x)mert
.
(c)
esetén a stabil alak log1p(1e-16) értéke körülbelül
.
A naiv log(1e16 + 1) - log(1e16) azért adhat
0.0-t, mert a float64 kerekítésében
pontosan
-ként
tárolódik, így a kivonás valódi különbsége elvész, mielőtt a logaritmus
megtörténhetne. A stabil alakban az
hányados
-ként
megmarad, és a log1p a kis argumentumot pontosan
kezeli.
Megoldás. (a) A centek
értéket adnak, ami túlcsordul a float64-ben:
inf. Az inf/inf hányados nan,
ezért a softmax nan lesz. (b) Az euró-alapú softmax:
z_eur = basket_cents / 100.0
p_eur = np.exp(z_eur) / np.exp(z_eur).sum()
# elméletileg: [0.300606, 0.332229, 0.367165]A cent-alapú stabil softmax:
z_st = basket_cents - basket_cents.max()
p_cent = np.exp(z_st) / np.exp(z_st).sum()
# elméletileg: [2.06e-9, 4.54e-5, 9.999546e-1]A két eredmény eltérő, mert a skálázás nem pusztán numerikus trükk, hanem a hőmérsékletet módosítja: a cent-skáláról euróra osztás a logitokat -zal osztja, így a valószínűségek laposabbá válnak. Ha az eredeti cent-logitok arányait akarjuk megtartani, a max-eltolásos cent-alapú stabilitás a helyes választás; az euróra osztás más modellt ad, amit a kísérleti jegyzőkönyvben explicit jelezni kell.
A leggyakoribb tévedés, hogy algebrailag azonos képleteket
numerikusan is felcserélhetőnek tekintünk. Ellenpélda a
sqrt(x**2+1)-x és az
pár: nagy
-nél
az első akár teljesen rossz, a második pontos marad. Itt kapcsolódunk
vissza a Határérték mint közelítési folyamat
kerekítésihiba-tanulságához: a véges pontosság miatt a lépésköz
csökkentése vagy a naiv képlet egyszerűsége nem mindig javít. A Vektorizálás és mért teljesítmény mérési fegyelme
pedig arra emlékeztet, hogy a numerikus egyezést ne szemre, hanem
np.allclose-zal és rögzített tűréssel ellenőrizzük. Az II. ellenőrző pont: adatból ellenőrzött
feature-tábla feature-táblában ezt az elvet követtük:
kontrollösszegek és toleranciás összehasonlítás.
Önellenőrző lista a laborhoz:
np.isfinite a nagy bemeneten is
igaz.np.logaddexp.reduce eredménye
tűrésen belül egyezik-e a kézi függvénnyel?A logsumexp pontos értékét a maximum kiemelésével kell
kiszámítani; a max(z) + log(n) csak felső korlát. A skalár
math.exp túlcsordulási kivétele és a NumPy tömbműveletében
keletkező inf nem azonos hibajelenség, ezért eltérő
ellenőrzést igényelhetnek.
A stabil numerika azt jelenti, hogy ugyanazt a matematikai célt olyan
alakban számoljuk, amely a float64 véges tartományán belül
marad. Az overflow inf-et, az underflow hamis
0-t, a cancellation pedig értékes jegyek elvesztését
okozza. A logsumexp stabil változata a maximum kiemelésével kerüli el az
overflow-t; a kézi
kontroll
-hez
közeli eredményt ad. A skálázás nem ártalmatlan: a softmax
hőmérsékletét, így a modell jelentését is megváltoztatja. A következő
alkalmazás a Tanítás, validáció és érintetlen
teszt prediktív pipeline lesz, ahol a log-loss stabil számítása és a
softmax kalibrációja ugyanezt a fegyelmet igényli.