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.
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.
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.
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.
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ó.
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.
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.
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
és
a két nyelv által számított érték,
az abszolút,
a relatív tolerancia. Nálunk
.
Például ha Pythonban
,
R-ben pedig
,
akkor a különbség körülbelül
,
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.
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.pyWindows PowerShellben ugyanez:
python export_transfer.pyA 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.
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
,
R-ben ugyanez a lebegőpontos ábrázolás miatt gyakorlatilag ugyanaz; a
toleranciafüggvény a
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.
É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?
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.
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.
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?
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?
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é.
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.
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.