37.3 Jelentés és nyelvek közötti adatcsere

Reprodukálhatósági ellenőrzés: Python–R adatcserénél teszteld a hiányértékeket, a faktor/kategória szinteket, a dátumok időzónáját és a 64 bites egész értékeket; a CSV nem garantálja a teljes típushű visszaolvasást.

37.3.1 Cél és konkrét helyzet

A Tábla és formula R-ben alfejezetben R-ben már dolgoztunk táblákkal, formulákkal és lineáris modellel. Most azt a gyakori munkamenetet nézzük meg, amikor a Pythonban elkészített elemzési táblát egy R-t használó kolléga statisztikai modellje, végül pedig egy Quarto-jelentés használja fel. A cél az, hogy az átadás ne egy „remélhetőleg ugyanaz” adatfájl legyen, hanem ellenőrzött szerződés: ugyanaz a séma, ugyanazok a kontrollértékek, és mindkét oldalon lefuttatott invariáns-ellenőrzés.

A konkrét helyzetben a megfigyelési egység az ügyfél. A Python oldal egy 6 soros, szándékosan kicsi szintetikus táblát készít: customer_id, segment, region, order_count, basket_sum_minor, late_rate és last_order_ts. Az adatot Parquet-ben, egy schema.json és egy controls.json kíséretében adjuk át. Az R oldal beolvassa, lefuttatja ugyanazokat az ellenőrzéseket, majd egy lm modellt és egy Quarto-jelentéstervet készít. Mielőtt bármit lefuttatnánk, kézzel becsüljük meg az eredményt: a basket_sum_minor összege a fenti hat sorra 81 060 EUR-cent, a late_rate átlaga körülbelül 0,161, a sorok száma 6.

37.3.2 Fogalmi magyarázat

37.3.2.1 Parquet és Arrow

A Parquet egy oszlopos, bináris fájlformátum, amely a sémát a fájlban hordozza. A pandas to_parquet hívása a pyarrow motoron keresztül Arrow memóriaformátumba konvertál, majd onnan ír Parquet-t. Az R oldalon az arrow csomag read_parquet függvénye ugyanazt a sémát próbálja visszaállítani. Előnye a CSV-vel szemben, hogy a int64, float64, timestamp[us, tz=UTC] és kategória típusok nem lapulnak puszta szöveggé. A kategória átvitele azonban nem garantáltan faktorrá alakul R-ben: a pandas category oszlop Parquet-ben gyakran dictionary típusú lesz, az R arrow pedig dictionary típusként olvashatja, nem közvetlenül factor-ként. Ezért a sémafájlban explicit levels listát adunk, és az R oldalon mi magunk alakítunk faktort.

37.3.2.2 CSV fallback

A CSV akkor jön szóba, ha a fogadó környezetben nincs Arrow/Parquet támogatás, vagy egy külső rendszer csak CSV-t fogad. A CSV karakterfolyam, tehát a típusinformáció elveszik. A fallback csak akkor biztonságos, ha a schema.json megadja a visszaállítási szabályokat: oszloptípus, időzóna, kategóriaszintek, hiányzó érték jelölése és pénznem/mértékegység. A gyakorlatban a CSV-t a readr::read_csv-vel olvassuk, majd a séma alapján explicit mutate vagy parse_* lépéseket hajtunk végre.

37.3.2.3 Quarto

A Quarto egy dokumentumformátum, amely Markdown-szöveget és futtatható kódblokkokat egyesít. Egy .qmd fájl lehet tisztán R, tisztán Python vagy akár kevert nyelvű. A quarto render report.qmd parancs a projekt virtuális környezetét és a bejegyzett kernelt használja. A jelentés attól lesz ellenőrzött, hogy a kódblokkja beolvassa az adatot, lefuttatja a kontrollokat, és csak ezután írja ki a modellt. Így a jelentés és az adatátadási szerződés együtt reprodukálható.

37.3.2.4 environment

A környezet nem a notebook és nem az IDE beállítása. A Python oldalon a projekt pyproject.toml és a feloldott uv.lock rögzíti a csomagokat; az R oldalon a renv.lock ugyanezt teszi. A Quarto-renderelés előtt ellenőrizni kell, hogy a kernelspec a megfelelő R környezetre mutat. A titkok, hozzáférési kulcsok és valós ügyféladatok továbbra sem kerülhetnek a schema.json-ba, a controls.json-ba vagy a jelentésbe; ezt már a Hozzáférés, titkok és adatminimalizálás is rögzítette. A sémafájl csak típus- és mértékegység-információt hordoz.

37.3.2.5 Schema

A semleges adatátadási szerződés nálunk egy JSON fájl. Minden oszlophoz tartozik név, típus, mértékegység, nullázhatóság, kategória esetén levels, időbélyeg esetén timezone. A szerződés akkor hasznos, ha mindkét oldal gépiesen ellenőrzi, nem csak elolvassa.

37.3.2.6 Adatmegmaradási invariánsok és numerikus tolerancia

A nyelvközi adatátadás matematikai alapja az invariáns-ellenőrzés. A következő mennyiségeknek kell egyezniük vagy tolerancián belül maradniuk:

A lebegőpontos összegek kerekítési hibája miatt szükség van toleranciára. Az [ |a-b| \le \text{atol} + \text{rtol} \cdot \max(|a|, |b|) ] képletben aa és bb a két nyelv által számított érték, atol\text{atol} az abszolút, rtol\text{rtol} a relatív tolerancia. Nálunk atol=rtol=10−9\text{atol}=\text{rtol}=10^{-9}. Például ha Pythonban x‾=0,1613095\bar x=0{,}1613095, R-ben pedig 0,161309499999999990{,}16130949999999999, akkor a különbség körülbelül 10−1810^{-18}, ami bőven a tolerancián belül van. Egész összegeknél viszont bitpontos egyezést várunk, mert az int64 nem kerekít.

37.3.3 Teljes minimális munkapélda

A példa feltételezi, hogy a projekt virtuális környezetében elérhető a pandas és a pyarrow, R-ben pedig az arrow, dplyr és jsonlite. A Python kódot a projekt gyökeréből futtatjuk. Az alábbi adat szintetikus, nem a Northstar lab fájlja; a cél a kis kézi ellenőrizhetőség.

# export_transfer.py
import json
from pathlib import Path

import pandas as pd

rows = {
    "customer_id": ["C001", "C002", "C003", "C004", "C005", "C006"],
    "segment": pd.Categorical(
        ["consumer", "business", "consumer", "business", "consumer", "business"],
        categories=["consumer", "business"],
    ),
    "region": pd.Categorical(
        ["north", "north", "central", "south", "central", "south"],
        categories=["north", "central", "south"],
    ),
    "order_count": [3, 7, 2, 5, 4, 8],
    "basket_sum_minor": [10250, 20480, 5310, 15220, 8700, 21100],
    "late_rate": [0.0, 0.142857, 0.5, 0.2, 0.0, 0.125],
    "last_order_ts": pd.to_datetime(
        [
            "2025-03-01T12:00:00+00:00",
            "2025-02-28T18:30:00+00:00",
            "2025-04-01T09:15:00+00:00",
            "2025-03-15T08:00:00+00:00",
            "2025-03-20T16:45:00+00:00",
            "2025-04-02T11:30:00+00:00",
        ]
    ).tz_convert("UTC"),
}

df = pd.DataFrame(rows)

transfer_dir = Path("transfer")
transfer_dir.mkdir(exist_ok=True)

df.to_parquet(transfer_dir / "customers.parquet", index=False)

schema = {
    "primary_key": "customer_id",
    "observation_unit": "customer",
    "columns": [
        {"name": "customer_id", "type": "string", "nullable": False},
        {
            "name": "segment",
            "type": "category",
            "levels": ["business", "consumer"],
            "nullable": False,
        },
        {
            "name": "region",
            "type": "category",
            "levels": ["central", "north", "south"],
            "nullable": False,
        },
        {"name": "order_count", "type": "int64", "unit": "count", "nullable": False},
        {
            "name": "basket_sum_minor",
            "type": "int64",
            "unit": "EUR cent",
            "nullable": False,
        },
        {
            "name": "late_rate",
            "type": "float64",
            "unit": "proportion",
            "range": [0.0, 1.0],
            "nullable": False,
        },
        {
            "name": "last_order_ts",
            "type": "timestamp[us]",
            "timezone": "UTC",
            "nullable": False,
        },
    ],
}

controls = {
    "row_count": int(len(df)),
    "sum_basket_minor": int(df["basket_sum_minor"].sum()),
    "mean_late_rate": float(df["late_rate"].mean()),
    "min_last_order_ts": df["last_order_ts"].min().isoformat(),
    "max_last_order_ts": df["last_order_ts"].max().isoformat(),
    "segment_levels": sorted(df["segment"].cat.categories.tolist()),
}

(transfer_dir / "schema.json").write_text(json.dumps(schema, indent=2))
(transfer_dir / "controls.json").write_text(json.dumps(controls, indent=2))

print(controls)

Linux/macOS terminálban a munkakönyvtár a projekt gyökere:

python export_transfer.py

Windows PowerShellben ugyanez:

python export_transfer.py

A futtatás elméletileg várt, szemléltető kimenete:

{'row_count': 6,
 'sum_basket_minor': 81060,
 'mean_late_rate': 0.16130950000000002,
 'min_last_order_ts': '2025-02-28T18:30:00+00:00',
 'max_last_order_ts': '2025-04-02T11:30:00+00:00',
 'segment_levels': ['business', 'consumer']}

Az R oldal ezt a szerződést olvassa és ellenőrzi:

# analyze_transfer.R
library(arrow)
library(dplyr)
library(jsonlite)

df <- read_parquet("transfer/customers.parquet")
schema <- fromJSON("transfer/schema.json")
controls <- fromJSON("transfer/controls.json")

stopifnot(nrow(df) == controls$row_count)

# Egész összeg: bitpontos egyezés
stopifnot(sum(df$basket_sum_minor) == controls$sum_basket_minor)

# Lebegőpontos átlag: numerikus tolerancia
tol <- function(a, b, atol = 1e-9, rtol = 1e-9) {
  abs(a - b) <= atol + rtol * max(abs(a), abs(b))
}
stopifnot(tol(mean(df$late_rate), controls$mean_late_rate))

# Kategóriaszintek ellenőrzése a séma alapján
seg_col <- schema$columns[[which(sapply(schema$columns, function(x) x$name == "segment"))]]
df <- df |>
  mutate(segment = factor(segment, levels = seg_col$levels))
stopifnot(setequal(levels(df$segment), controls$segment_levels))

# Időbélyeg kontroll UTC-ben
stopifnot(as.character(min(df$last_order_ts)) == controls$min_last_order_ts)
stopifnot(as.character(max(df$last_order_ts)) == controls$max_last_order_ts)

fit <- lm(late_rate ~ segment + log1p(basket_sum_minor), data = df)
summary(fit)

A modellösszegzés elméletileg várt, szemléltető részlete: a segmentconsumer együttható előjele a kis mintán nem értelmezhető okságként; a példa csak a gépi átadást ellenőrzi.

A Quarto-jelentés terve egy report.qmd fájl, amely ugyanezt a kontrollt futtatja le rendereléskor:

---
title: "Nyelvek közötti átadás kontrollja"
format: html
---

```{r}
library(arrow)
library(dplyr)
library(jsonlite)

df <- read_parquet("transfer/customers.parquet")
controls <- fromJSON("transfer/controls.json")

stopifnot(nrow(df) == controls$row_count)
stopifnot(tol(mean(df$late_rate), controls$mean_late_rate))

cat("Ellenőrzött sorok száma:", nrow(df), "\n")
cat("Ellenőrzött átlag:", mean(df$late_rate), "\n")

A renderelés Linux/macOS terminálból:

```bash
quarto render report.qmd

Windows PowerShellben ugyanez. A parancs feltételezi, hogy a Quarto CLI telepítve van, és a projekt R kernelspecje a renv környezetre mutat.

37.3.4 Eredmény értelmezése

A példában a Python oldali controls.json és az R oldali ellenőrzés ugyanazokat a számokat adja. Az egész sum_basket_minor = 81060 bitpontosan egyezik, mert int64 összegről van szó. A mean_late_rate értéke Pythonban 0,161309500000000020{,}16130950000000002, R-ben ugyanez a lebegőpontos ábrázolás miatt gyakorlatilag ugyanaz; a toleranciafüggvény a 10−910^{-9} relatív hibát is megengedi, tehát a kontroll akkor is zöld, ha az utolsó tizedesjegyekben platformfüggő eltérés van. A kategória-ellenőrzés az egyetlen olyan pont, ahol a Parquet önmagában nem elég: a segment oszlop R-ben lehet dictionary vagy karakter, ezért a schema.json levels listájából explicit faktort készítünk. A Quarto-jelentés azért megbízható, mert a renderelés közben újra lefuttatja a kontrollt, így a jelentés kimenete nem válhat el a tényleges adattól.

37.3.5 Feladatok

  1. Értelmezés. Miért szerepel külön timezone mező a schema.json-ban akkor is, ha a Parquet fájl hordozza az időbélyeg típusát? Mi történik, ha egy CSV fallbackben a last_order_ts string "2025-04-02T11:30:00+02:00" és "2025-04-02T11:30:00+00:00" értékeket tartalmaz, és R-ben karakterként számolunk max-ot?

  2. Megvalósítás. Írj Python függvényt validate_schema(df, schema) néven, amely ellenőrzi, hogy a DataFrame oszlopai megfelelnek a séma type, nullable, kategória levels és időbélyeg timezone mezőinek. A függvény hibás egyezés esetén ValueError kivételt dobjon, pontos üzenettel.

  3. Hibakeresés. Az R oldali kódban a setequal(levels(df$segment), controls$segment_levels) hamisat ad, mert az arrow::read_parquet a segment oszlopot karakterként olvasta be, nem faktorként. Javítsd a kódot úgy, hogy a schema.json levels mezőjéből explicit faktort készíts, és magyarázd el, miért nem elég a Parquet beolvasásra hagyatkozni.

  4. Megváltozott feltétel. Az adatgazda új currency oszlopot ad a táblához, amely minden sorban "EUR" string. Módosítsd a Python exportot úgy, hogy az új oszlop is bekerüljön a DataFrame-be, a schema.json-ba és a controls.json-ba. Az R ellenőrzést egészítsd ki stopifnot(all(df$currency == controls$currency_value)) teszttel. Miért nem elég a Parquet fájlt lecserélni a controls.json frissítése nélkül?

  5. Továbbvitel. Az adatkészletet egy olyan fogyasztó kapja meg, amely csak CSV-t tud olvasni, és a rendszer lokálja tizedesvesszőt használ. Miért okozhat hibát a late_rate oszlop CSV-írása, és hogyan lehet a write.csv vagy readr::write_csv paramétereivel a hibát elkerülni?

37.3.6 Megoldások és indoklás

1. megoldás. A Parquet fájl valóban hordozza a timestamp[us, tz=UTC] típust, de a sémafájl akkor is kötelező, mert a CSV fallback nem hordoz típust, és a fogyasztó oldalán a helyes as.POSIXct híváshoz explicite tudni kell az időzónát. A példában "2025-04-02T11:30:00+02:00" időben korábbi, mint "2025-04-02T11:30:00+00:00", de lexikografikus string-összehasonlításban a "+02:00" nagyobb, mint a "+00:00", tehát a max() karakterként rossz értéket ad. Az időbélyegeket mindig UTC-re konvertálva, numerikus/POSIXct típusként kell összehasonlítani.

2. megoldás. Egy lehetséges ellenőrző függvény:

import pandas as pd

def validate_schema(df: pd.DataFrame, schema: dict) -> None:
    cols = {c["name"]: c for c in schema["columns"]}
    if set(df.columns) != set(cols):
        raise ValueError(f"Oszlopok eltérnek: {set(df.columns) ^ set(cols)}")
    for name, spec in cols.items():
        s = df[name]
        if spec["nullable"] is False and s.isna().any():
            raise ValueError(f"{name}: nem várt hiányzó érték")
        if spec["type"] == "int64" and not pd.api.types.is_integer_dtype(s):
            raise ValueError(f"{name}: int64 várt, kapott {s.dtype}")
        if spec["type"] == "float64" and not pd.api.types.is_float_dtype(s):
            raise ValueError(f"{name}: float64 várt, kapott {s.dtype}")
        if spec["type"] == "category":
            if not isinstance(s.dtype, pd.CategoricalDtype):
                raise ValueError(f"{name}: category várt, kapott {s.dtype}")
            actual = set(s.cat.categories)
            expected = set(spec["levels"])
            if actual != expected:
                raise ValueError(f"{name}: szintek {actual} != {expected}")
        if spec["type"].startswith("timestamp") and spec.get("timezone") == "UTC":
            if not pd.api.types.is_datetime64tz_dtype(s) or s.dt.tz.zone != "UTC":
                raise ValueError(f"{name}: UTC idős bélyeg várt")

Az ellenőrzés a not s.isna().any() feltétellel a nullable=False szerződést tartatja be.

3. megoldás. A javított R kód:

seg_spec <- schema$columns[[which(sapply(schema$columns, function(x) x$name == "segment"))]]
df <- df |>
  mutate(segment = factor(as.character(segment), levels = seg_spec$levels))
stopifnot(setequal(levels(df$segment), controls$segment_levels))

A Parquet beolvasás önmagában nem garantálja a factor osztályt, mert a dictionary típus R-ben lehet vctrs- vagy arrow-specifikus, és a levels halmaza sem feltétlenül egyezik a séma szerinti rendezéssel. A factor(..., levels = ...) hívás explicitté teszi a szerződést, így az ellenőrzés nem a beolvasó implementációjától függ.

4. megoldás. A Python oldalon a DataFrame-hez adjuk hozzá:

df["currency"] = "EUR"

A schema.json columns listájához:

{"name": "currency", "type": "string", "unit": "ISO 4217 code", "nullable": false}

A controls.json-hoz:

"currency_value": "EUR",

Az R oldal:

stopifnot(all(df$currency == controls$currency_value))

A controls.json frissítése azért kötelező, mert az R ellenőrzés a szerződésfájlhoz hasonlít. Ha csak a Parquet-ot cseréljük, a régi controls.json nem tartalmazza az új mezőt, ezért az all(df$currency == controls$currency_value) NULL-t adhat, vagy az egész pipeline elhallgatva hamisan zöld marad. A szerződésfájlok és az adat mindig együtt, egy verziózott kötegként mozogjanak.

5. megoldás. Ha a rendszer lokálja tizedesvesszőt használ, a late_rate oszlop értékei "0,142857" alakban kerülhetnek a CSV-be. Az R read_csv alapértelmezésben pontot vár tizedesjegy-elválasztónak, ezért az ilyen sorokat NA-nak vagy karakternek olvassa. A Python oldalon df.to_csv(..., decimal=".") explicit módon pontot kényszerít, vagy az R oldalon readr::read_csv(..., locale = locale(decimal_mark = ",")) állítható. A sémafájlban érdemes rögzíteni a CSV-elválasztó és tizedesjel konvenciót is, mert az átadás így válik lokálfüggetlenné.

37.3.7 Gyakori tévedés

A leggyakoribb hiba az, hogy a Parquet sémáját azonosnak tekintjük a szemantikai szerződéssel. Példa: a pandas category oszlop Parquet-ben dictionary típusú lesz, az R arrow ezt karakterként vagy dictionary-ként olvashatja. Ha az R kód factor(df$segment) hívást használ, a szintek ABC-sorrendbe kerülnek, ami eltérhet a Python oldali levels sorrendtől; a lm kontrasztkódolás ekkor más referencia-kategóriát választhat, és az együttható-értelmezés félrevezetővé válik. Hasonlóan, a CSV fallbacknél az időzóna elvesztése nem okoz azonnali hibát az átlagoknál, de a max(last_order_ts) karakteres összehasonlítással hamis eredményt ad. A megoldás mindig ugyanaz: explicit schema, explicit faktor/type visszaállítás és kontrollfutás, nem pedig a beolvasófüggvény alapértelmezésére hagyatkozás. Ez a fegyelem rokon a Feature-szelekció és magas dimenzió ott tanult elvével: a gépi kényelem nem helyettesíti a szerződéses ellenőrzést.

37.3.8 Rövid összefoglaló

A nyelvek közötti adatátadás akkor megbízható, ha a Parquet vagy CSV fájl mellett egy explicit schema.json és egy controls.json is utazik. A séma rögzíti a típust, mértékegységet, kategóriaszinteket és időzónát; a kontrollok a sorok számát, egész összegeket, lebegőpontos átlagokat és időintervallum-határokat tartalmazzák. A lebegőpontos értékekre az abszolút-relatív tolerancia képlet érvényes, az egész összegekre bitpontos egyezés. R-ben a kategóriákat a séma levels mezőjéből explicit faktorként állítjuk helyre, mert a Parquet dictionary beolvasás nem garantálja ezt. A Quarto-jelentés a rendereléskor újra lefuttatja a kontrollokat, így a jelentés nem válhat el az ellenőrzött adattól. Következő alkalmazásként a Mikor érdemes másik ökoszisztémát választani? azt vizsgálja meg, mikor érdemes egyáltalán másik ökoszisztémát választani.