5.3 Lebegőpont, hiányzó érték és stabil számítás

5.3.1 Cél és konkrét helyzet

A Northstar-adatszerződés pénzösszegeket EUR-centben, egész minor unitban tárol: basket_minor, shipping_fee_minor és az összeg basket_minor + shipping_fee_minor is egész int64. A gyakorlatban mégis gyakran euróra váltják az értékeket float64 osztással, mert a megjelenítés vagy egy modell inputja ezt kéri. Ebből két kérdés lesz:

  1. Milyen hibát visz be a lebegőpontos ábrázolás, és hogyan ellenőrzünk toleranciával?
  2. Mikor maradjon minden pénz egész centben, és mikor elfogadható a float64?

A konkrét bemenet most öt képzeletbeli rendelés: [1499, 2300, 8750, 0, 100] cent kosárérték és [299, 0, 599, 0, 499] cent szállítási díj. A megfigyelési egység egy rendelés; a cél a rendelésenkénti total_minor pontos előállítása és annak ellenőrzése, hogy a lebegőpontos euró-kerülőút nem rontja-e el a centet. Előzetes becslés: egyetlen osztás-szorzás körüli hiba jellemzően 10^-16 relatív nagyságrendű, így öt soron nem várunk centhibát, de a pontos == ellenőrzés attól még hibás lehet.

5.3.2 Fogalmi magyarázat

5.3.2.1 float64: korlátozott pontosság, nem „valós szám”

A np.float64 az IEEE 754 double precision formátum: 1 előjelbit, 11 kitevőbit és 52 tárolt mantisszabit; a normalizált értékeknél egy implicit vezető bit miatt a pontosság 53 bit. A gép-epsilon

ϵm=2−52≈2,22⋅10−16 \epsilon_m = 2^{-52} \approx 2{,}22 \cdot 10^{-16}

azt jelenti, hogy egy kerekítés relatív hibája legfeljebb ϵm/2≈1,11⋅10−16\epsilon_m/2 \approx 1{,}11 \cdot 10^{-16}, ha nem lépünk át nagyságrendi határt. A tizedes 0.1 binárisan végtelen tört, ezért a tárolt érték csak közelíti a tizedes 0.1-et. Ennek következménye a jól ismert példa: 0.1 + 0.2 0.30000000000000004 lesz, nem pontosan 0.3.

A hibát két mennyiséggel mérjük. Ha x*x^* a számított közelítés és xx a referencia, akkor az abszolút hiba:

Δ=|x*−x|, \Delta = |x^* - x|,

a relatív hiba pedig x≠0x \neq 0 esetén:

δ=Δ|x|. \delta = \frac{\Delta}{|x|}.

A 0.1 + 0.2 példában Δ≈5,6⋅10−17\Delta \approx 5{,}6 \cdot 10^{-17}, δ≈1,9⋅10−16\delta \approx 1{,}9 \cdot 10^{-16}. Ez messze kisebb egy centnél (0,010{,}01), tehát a hiba nagyságrendje más, mint a pénzügyi kerekítés hibája. Ezért hasznos külön beszélni a numerikus toleranciáról és az üzleti kerekítésről.

5.3.2.2 integer overflow

A Python int tetszőleges pontosságú, de a np.int64 rögzített 64 bites, előjeles szám, tartománya:

[−263,263−1]≈[−9,22⋅1018,9,22⋅1018]. [-2^{63},\, 2^{63}-1] \approx [-9{,}22 \cdot 10^{18},\, 9{,}22 \cdot 10^{18}].

Túlcsorduláskor a NumPy nem dob hibát alapértelmezésben, hanem modulo 2642^{64} visszatekeredik. A Northstar értékei bőven e tartomány alatt vannak, ezért az int64 centbiztos. Ha viszont például egy azonosítót véletlenül numerikus oszlopként olvasnánk be és szoroznánk össze, a túlcsordulás csendben hamis eredményt adhat.

5.3.2.3 NaN és isfinite

A np.nan (not a number) a hiányzó vagy érvénytelen lebegőpontos érték jelölője. Fontos tulajdonságai:

Egy int64 tömbben nem tárolhatunk nan-t; a Northstar nyers adatában a hiányzó distance_km például betöltés után float64 tömbben lehet nan.

5.3.2.4 allclose: toleráns összehasonlítás

A bitazonos == lebegőpontos számítás után általában rossz ellenőrzés. A np.allclose(a, b, rtol=1e-05, atol=1e-08, equal_nan=False) elemenként ezt a feltételt nézi:

|ai−bi|≤atol+rtol⋅|bi|. |a_i - b_i| \le \text{atol} + \text{rtol}\cdot |b_i|.

Itt aia_i és bib_i a két azonos shape-ű tömb megfelelő eleme. Az atol abszolút tűrés, az rtol relatív tűrés: az rtol * |b_i| tag miatt a feltétel nagy értékeknél is megengedőbb, az atol miatt nulla körül sem válik lehetetlenné. A függvény egyetlen logikai értéket ad, ha minden elem megfelel. Az equal_nan=True használatával explicit módon elfogadhatjuk, hogy két nan „egyenlő”, de ezt csak tudatos döntésként szabad bekapcsolni.

5.3.2.5 Összegzési hiba

A lebegőpontos összeadás nem asszociatív: minden részösszeg kerekítődik, így egy nagy érték mellé adott kis érték elveszhet. A np.sum páros összegzést használ, ami csökkenti, de nem szünteti meg a hibát; a Python math.fsum kompenzált összegzése sokkal pontosabb. A pénz centben tartásának egyik előnye, hogy az egész összeadásnak nincs ilyen kerekítési hibája.

5.3.3 Teljes minimális munkapélda

A következő kód a Northstar-szerű adatot egész centben és lebegőpontos euró-kerülőúton is feldolgozza. A kimenet szemléltető, elméletileg várt érték; ebben a generálásban nem futott Python-kód.

import math

import numpy as np

# Bemenet: 5 rendelés, Northstar-szerű egész cent
basket_minor = np.array([1499, 2300, 8750, 0, 100], dtype=np.int64)
shipping_fee_minor = np.array([299, 0, 599, 0, 499], dtype=np.int64)

# 1) Egész út: a szerződés szerinti total_minor
total_minor_int = basket_minor + shipping_fee_minor
print("total_minor_int:", total_minor_int, total_minor_int.dtype)

# 2) Lebegőpontos euró út
total_eur = (basket_minor.astype(np.float64) / 100.0
             + shipping_fee_minor.astype(np.float64) / 100.0)
total_minor_back = np.rint(total_eur * 100.0).astype(np.int64)
print("vissza centbe:", total_minor_back)

# 3) Bitazonos és toleráns ellenőrzés
print("bitazonos:", np.array_equal(total_minor_int, total_minor_back))
print("allclose tűrés nélkül:",
      np.allclose(total_minor_int, total_minor_back, rtol=0.0, atol=0.0))
print("allclose centes euró-ellenőrzés:",
      np.allclose(total_eur,
                  total_minor_int.astype(np.float64) / 100.0,
                  rtol=1e-12, atol=1e-12))

# 4) A híres egyenlőség-ellenpélda
x = 0.1 + 0.2
print(repr(x), "==", x == 0.3, "isclose:", np.isclose(x, 0.3))

# 5) Integer overflow, NaN, összegzési hiba
print("int64 max + 1:", np.int64(2**63 - 1) + np.int64(1))
v = np.array([1.0, np.nan, np.inf, -np.inf])
print("v == v:", v == v)
print("isnan:", np.isnan(v))
print("isfinite:", np.isfinite(v))
a = np.array([1e16, 1.0, -1e16], dtype=np.float64)
print("np.sum:", a.sum())
print("math.fsum:", math.fsum(a))

A várt, szemléltető kimenet:

total_minor_int: [1798 2300 9349    0  599] int64
vissza centbe: [1798 2300 9349    0  599]
bitazonos: True
allclose tűrés nélkül: True
allclose centes euró-ellenőrzés: True
0.30000000000000004 == False isclose: True
int64 max + 1: -9223372036854775808
v == v: [ True False  True  True]
isnan: [False  True False False]
isfinite: [ True False False False]
np.sum: 0.0
math.fsum: 1.0

5.3.4 Eredmény értelmezése

A total_minor_int shape-je (5,), dtype-ja int64, és az összeadás kerekítés nélkül pontos, mert egész centeket ad össze. A lebegőpontos úton az euróra váltás bevezet ugyan egy 10−1610^{-16} nagyságrendű hibát, de a np.rint(total_eur * 100.0) centes kerekítés itt éppen visszaadja az egész értéket, ezért a bitazonos ellenőrzés is igaz. Ez nem általános garancia: a centes kerekítés eltünteti a kis hibát, de ha a kód a kerekítés előtt hasonlít össze ==-gyel, a 0.1 + 0.2 == 0.3 példa hamis lesz. Az allclose itt rtol=1e-12, atol=1e-12 mellett is igaz, mert az osztás-szorzás hibája jóval ez alatt van. A nagy int64 érték túlcsordulása csendben negatív számot ad; a NaN összehasonlítása önmagával hamis; a [1e16, 1, -1e16] naiv np.sum összege 0.0, miközben a kompenzált math.fsum 1.0-t ad.

Hiányzó adat és nem véges szám elkülönítése. A np.nan lebegőpontos hiányjelző, a np.inf/-np.inf nem véges numerikus érték, a pd.NA pedig a pandas kiterjesztett, háromértékű logikájában szereplő hiányjelzés; ezek nem felcserélhetők. Az np.isnan, np.isfinite és a pandas isna műveletei eltérő típusfeltételekkel használhatók.

Numerikus ellenpróba: számold ki ugyanazt a kifejezést matematikailag ekvivalens, de numerikusan eltérő alakban (például közeli számok kivonásával), és vizsgáld az abszolút, illetve relatív hibát. Az eredményhez mindig add meg a lebegőpontos típust, a bemeneti skálát és a toleranciát.

5.3.5 Feladatok

5.3.5.1 1. Értelmezés: hiba és tűrés

Adott a = 0.1, b = 0.2, referencia r = 0.3 tizedesként. Számítsd ki az a + b float64 közelítés és r abszolút, valamint relatív hibáját! Hány nagyságrenddel kisebb ez, mint 0.01 abszolút hibája? Fogalmazd meg, miért hibás így ellenőrizni egy fizetési egyenleget: if (0.1 + 0.2) == 0.3: ...

5.3.5.2 2. Implementálás: cent-kerülőút diagnosztika

Írj függvényt check_roundtrip(minor_cents) néven, amely int64 centértékeket float64 euróra vált, majd np.rint(eur * 100).astype(np.int64) módon visszaállít centbe, és visszaadja azon indexeket, ahol az eredeti és a visszaállított érték nem egyezik. Generálj rng = np.random.default_rng(20261020) seeddel 10_000 véletlen centértéket a [0, 10_000_000) intervallumból, futtasd az ellenőrzést, és add meg a hibás sorok arányát! Miért nem jelenti az eredmény azt, hogy a lebegőpontos út mindig biztonságos?

5.3.5.3 3. Hibadiagnózis: NaN-szűrés

Az alábbi kód egy hiányos értékeket tartalmazó sort szeretne megtisztítani, de rossz eredményt ad. Mi a hiba?

import numpy as np

values = np.array([100.0, np.nan, 50.0])
clean = values[values == np.nan]  # a készítő szándéka: nan kihagyása
print(clean)
total = np.sum(values)
print(total)

Javítsd np.isnan segítségével, majd mutasd meg az np.nansum alternatívát is! Milyen shape-ű tömböt kapsz a javítás után?

5.3.5.4 4. Megváltozott feltétel: float32 és sok sor

Tegyük fel, hogy ugyanezt a rendszert USD pennyben tároló szolgáltatásra viszed át, és egy riport minden sort float32 euróra vált, mielőtt 10 millió sort összegez. A float32 gép-epsilonja ϵ32≈1,19⋅10−7\epsilon_{32} \approx 1{,}19 \cdot 10^{-7}, a kerekítés relatív hibája legfeljebb ϵ32/2≈6⋅10−8\epsilon_{32}/2 \approx 6 \cdot 10^{-8}. Egy 100 dolláros sor abszolút hibájának felső becslése mennyi? Ha a hibák azonos előjelűek maradnának, mekkora lehet a 10 millió soros összeg abszolút hibája? Milyen tárolási döntést hoznál, és miért?

5.3.6 Megoldások és indoklás

5.3.6.1 1. megoldás

A float64 0.1 + 0.2 eredménye 0.30000000000000004. A különbség:

Δ=|0,30000000000000004−0,3|≈5,6⋅10−17. \Delta = |0{,}30000000000000004 - 0{,}3| \approx 5{,}6 \cdot 10^{-17}.

A relatív hiba:

δ=Δ0,3≈1,9⋅10−16. \delta = \frac{\Delta}{0{,}3} \approx 1{,}9 \cdot 10^{-16}.

Ez 0.01-hez képest 5,6⋅10−17/0,01≈5,6⋅10−155{,}6 \cdot 10^{-17} / 0{,}01 \approx 5{,}6 \cdot 10^{-15} szorzóval, tehát körülbelül 15 nagyságrenddel kisebb. A == ellenőrzés azért hibás, mert a tizedes 0.3-nak nincs pontos bináris reprezentációja; a kiszámított érték a legközelebbi float64, ezért a bitminta különbözik attól a float64-től, amely a 0.3 literált tárolja. Pénzügyi ellenőrzésnél centben egész számokkal hasonlítsunk, vagy centes kerekítés után np.array_equal-t, illetve megfelelő atol/rtol mellett np.allclose-t használjunk.

5.3.6.2 2. megoldás

import numpy as np

def check_roundtrip(minor_cents: np.ndarray) -> np.ndarray:
    eur = minor_cents.astype(np.float64) / 100.0
    back = np.rint(eur * 100.0).astype(np.int64)
    return np.flatnonzero(back != minor_cents)

rng = np.random.default_rng(20261020)
cents = rng.integers(0, 10_000_000, size=10_000, dtype=np.int64)
bad = check_roundtrip(cents)
print("hibás indexek száma:", bad.size)
print("hibás arány:", bad.size / cents.size)

A várt eredmény a levezetés szerint 0 hibás sor, mert a centes kerekítés elnyeli a float64 reprezentációs hibát. Ez mégsem jelenti azt, hogy a lebegőpontos út általánosan biztonságos: a hiba a kerekítés előtti összehasonlításokban, kivonásokban, nagy értékeknél vagy float32 használatakor jelenhet meg. Az egész centes út garantáltan pontos, ezért ahol a szerződés centet ír elő, ott maradjunk int64-nél.

5.3.6.3 3. megoldás

A values == np.nan minden elemre hamis, mert a nan önmagával sem egyenlő, így clean üres (0,) shape-ű tömb, az összeg pedig nan lesz. Javítás:

import numpy as np

values = np.array([100.0, np.nan, 50.0])
clean = values[~np.isnan(values)]
print(clean)        # [100.  50.]
print(clean.sum())  # 150.0
print(np.nansum(values))  # 150.0

A javított clean shape-je (2,), dtype-ja float64, és az összeg 150.0. Ellenőrző invariant: clean csak véges értékeket tartalmaz, azaz np.isfinite(clean).all() igaz.

5.3.6.4 4. megoldás

Egy 100 dolláros sor abszolút hibája legfeljebb:

100⋅6⋅10−8=6⋅10−6 USD=0,0006 cent. 100 \cdot 6 \cdot 10^{-8} = 6 \cdot 10^{-6} \text{ USD} = 0{,}0006 \text{ cent}.

Ha 10 millió sor hibája azonos előjelű lenne, az abszolút hiba felső becslése:

107⋅6⋅10−6=60 USD. 10^7 \cdot 6 \cdot 10^{-6} = 60 \text{ USD}.

Ez elfogadhatatlan. A pontos tárolás int64 pennyben maradjon; ha lebegőpontos összegzésre van szükség, használjunk float64-et, akkor soronként 100⋅10−16≈10−14100 \cdot 10^{-16} \approx 10^{-14} USD, 10 millió sorra pedig legfeljebb 10−710^{-7} USD, ami elhanyagolható a centhez képest. A valós, véletlenszerű előjelű hibák részben kiejthetik egymást, de a legrosszabb esetre tervezett becslés megbízhatóbb, mint a szerencsére hagyatkozás.

5.3.7 Gyakori tévedés ellenpéldával

A leggyakoribb hiba, hogy a nan-t közönséges számként hasonlítják, vagy hogy lebegőpontos eredményt bitazonosan ellenőriznek. Ellenpélda:

import numpy as np

missing = np.nan
print(missing == missing)  # False

a = 0.1 + 0.2
print(a == 0.3)            # False

Az első esetben a False nem jelenti, hogy két különböző értékünk van; a nan definíció szerint soha nem egyenlő semmivel, még önmagával sem. A második esetben a False ellenére a számított érték egy cent pontossággal helyes. A helyes szűrés np.isnan, a helyes numerikus összehasonlítás np.allclose vagy np.isclose. A s018-ból ismert shape-ellenőrzés itt is hasznos: mielőtt toleranciát választunk, győződjünk meg róla, hogy a és b ugyanazt a (n,) vagy (n,1) alakot jelenti, különben a broadcasting csendben más összehasonlítást végezhet. A s015 metrika-moduljának mintájára a centösszeg definícióját is érdemes egyetlen függvényben tartani, hogy a notebook és a script ne két különböző kerekítést használjon. Az s003 munkanapló pedig akkor lesz becsületes, ha a futtatott verzió mellett azt is rögzíti, hogy int64 centút vagy float64 euróút készítette a jelentést.

Önellenőrzés: meg tudom-e mondani, hogy a float64 relatív hibája milyen nagyságrendű? Meg tudom-e indokolni, miért nem ==-gyel ellenőrzök számított lebegőpontos összeget? Ki tudom-e választani, mikor kell np.isnan a nan detektálásához? Felismerem-e a csendes int64 túlcsordulást egy nagy értékű szorzásnál?

5.3.8 Rövid összefoglaló

A float64 15–17 tizedesjegy pontosságú közelítés, ezért a tizedes törtek egy része nem ábrázolható pontosan; a 0.1 + 0.2 == 0.3 ezért hamis. Az abszolút hiba |x^* - x|, a relatív hiba |x^* - x| / |x|, és a float64 relatív hibája 10^-16 nagyságrendű. A np.allclose az atol + rtol * |b| képlettel ad toleráns feltételt. A nan nem közönséges szám, ezért np.isnan és np.isfinite kell; az int64 csendesen túlcsordulhat. A Northstar pénzadatot ezért érdemes egész centben tartani, és csak a megjelenítéskor euróra váltani; ha lebegőpontos út kell, a hibát nagyságrenddel és toleranciákkal ellenőrizzük. A következő alfejezetben a vektorizálást és a mért teljesítményt vizsgáljuk ezekkel az adattípusokkal.