ea6678ec23
Añade dos funciones impuras dict-no-throw, deterministas por seed, al dominio datascience (grupo eda): - generate_synthetic_eda_table: una tabla DuckDB de 19 columnas (numéricas correlacionadas + outliers, categóricas desbalanceadas, texto largo multi-idioma es/en/fr, fecha DATE, lat/lon válidas, PII email/iban/phone/uuid, nulos con patrón MCAR/MAR co-ocurrentes). Activa 14 capítulos del motor AutomaticEDA (num_distr, cat_distr, text_distr, calidad, missingness, correlacion, relaciones, modelos, timeseries, geospatial, agregacion, glosario + portada/overview). - generate_synthetic_eda_folder: 3 CSV relacionados (customers/orders/reviews) con FK customer detectable por containment, para el EDA de carpeta multi-tabla. Determinismo via Faker.seed_instance + numpy.default_rng. Tests: 16 passed (incluye determinismo por hash, rangos lat/lon, co-nulos income/spending, mediana palabras review >=20, phone formato internacional, FK containment). Añade faker (40.27.0) a python/pyproject.toml + uv.lock. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
315 lines
12 KiB
Python
315 lines
12 KiB
Python
"""generate_synthetic_eda_table — fixture sintetico para ejercitar el motor AutomaticEDA.
|
|
|
|
Funcion impura (escribe un archivo DuckDB a disco) y determinista por ``seed``:
|
|
construye una unica tabla cuyo CONTENIDO esta disenado para ACTIVAR el maximo
|
|
numero de capitulos del motor AutomaticEDA del grupo `eda` (num_distr, cat_distr,
|
|
text_distr, correlacion, missingness, modelos, timeseries, geospatial, relaciones,
|
|
calidad, agregacion) y los detectores semanticos / PII (`infer_semantic_type`).
|
|
|
|
Estilo dict-no-throw del grupo `eda`: NUNCA lanza; captura cualquier error y
|
|
devuelve ``{"status": "error", "error": str}``.
|
|
|
|
Determinismo: con el mismo ``seed`` el DataFrame y, por tanto, la tabla DuckDB
|
|
resultante son identicos byte a byte. Se siembra Faker (``Faker.seed``) y numpy
|
|
(``np.random.default_rng(seed)``) al inicio de cada generacion.
|
|
"""
|
|
|
|
import re
|
|
|
|
# Lista fija de paises (12 -> cardinalidad media para cat_distr / agregacion).
|
|
_COUNTRIES = [
|
|
"ES", "FR", "DE", "IT", "PT", "NL",
|
|
"BE", "US", "GB", "IE", "SE", "PL",
|
|
]
|
|
|
|
# Lista fija de categorias de producto (6 -> cardinalidad media).
|
|
_CATEGORIES = [
|
|
"electronics", "clothing", "home", "sports", "books", "toys",
|
|
]
|
|
|
|
# Niveles de plan con probabilidades DESBALANCEADAS (entropia baja para cat_distr).
|
|
_PLANS = ["baja", "media", "alta"]
|
|
_PLAN_PROBS = [0.70, 0.25, 0.05]
|
|
|
|
# Centroides (lat, lon) aproximados por pais: muestrean coordenadas validas
|
|
# dentro de [-90, 90] x [-180, 180] para que detect_latlon_columns las acepte.
|
|
_CENTROIDS = {
|
|
"ES": (40.4, -3.7), "FR": (46.6, 2.2), "DE": (51.1, 10.4), "IT": (41.9, 12.5),
|
|
"PT": (39.4, -8.2), "NL": (52.1, 5.3), "BE": (50.5, 4.5), "US": (39.0, -98.0),
|
|
"GB": (54.0, -2.0), "IE": (53.4, -8.0), "SE": (60.1, 18.6), "PL": (52.0, 19.1),
|
|
}
|
|
|
|
# Locales rotados para generar texto multi-idioma (es/en/fr).
|
|
_TEXT_LOCALES = ["es_ES", "en_US", "fr_FR"]
|
|
|
|
# Identificador SQL valido (DuckDB no parametriza el nombre de tabla en DDL).
|
|
_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
|
|
|
|
def _make_fakers(seed):
|
|
"""Crea los Faker por locale tras sembrar el generador compartido.
|
|
|
|
``Faker.seed(seed)`` siembra el ``random.Random`` compartido por todas las
|
|
instancias Faker que usan el generador por defecto, asi que el orden de
|
|
llamadas determina por completo la salida (determinismo).
|
|
"""
|
|
from faker import Faker
|
|
|
|
Faker.seed(seed)
|
|
es_es, en_us, fr_fr = (Faker(loc) for loc in _TEXT_LOCALES)
|
|
return {"es_ES": es_es, "en_US": en_us, "fr_FR": fr_fr}
|
|
|
|
|
|
# Texto duplicado canonico (multi-idioma, > 20 palabras) que se inyecta en una
|
|
# fraccion de las filas para que el analisis de duplicados exactos lo detecte.
|
|
_DUP_REVIEW = (
|
|
"Servicio excelente y entrega muy rapida, el producto llego en perfecto "
|
|
"estado y coincide con la descripcion publicada en la tienda. The customer "
|
|
"support team answered every question quickly and the packaging was solid "
|
|
"and well protected during shipping. Je recommande vivement ce vendeur a "
|
|
"tous mes amis, la qualite est vraiment au rendez-vous cette fois."
|
|
)
|
|
|
|
|
|
def _make_reviews(n, rng, fakers, dup_frac=0.04, null_frac=0.08):
|
|
"""Genera ``n`` reviews de texto libre largo multi-idioma (es/en/fr).
|
|
|
|
Cada review concatena dos parrafos de Faker en el idioma rotado por fila, de
|
|
modo que la MEDIANA de palabras por documento queda muy por encima de 20 y la
|
|
media de caracteres por encima de 50 (gates del capitulo text_distr). Se
|
|
inyectan duplicados exactos (``dup_frac``) y nulos (``null_frac``).
|
|
|
|
Devuelve una ``list`` de ``str`` o ``None`` (nulos) de longitud ``n``.
|
|
"""
|
|
# Numero de frases por parrafo precomputado con numpy (determinista) para no
|
|
# interleavar draws de rng dentro del bucle de faker.
|
|
nb1 = rng.integers(4, 8, n)
|
|
nb2 = rng.integers(3, 7, n)
|
|
|
|
reviews = []
|
|
for i in range(n):
|
|
fk = fakers[_TEXT_LOCALES[i % 3]]
|
|
p1 = fk.paragraph(nb_sentences=int(nb1[i]))
|
|
p2 = fk.paragraph(nb_sentences=int(nb2[i]))
|
|
reviews.append(f"{p1} {p2}")
|
|
|
|
# Duplicados exactos: una fraccion de filas comparte un review identico.
|
|
if n > 0 and dup_frac > 0:
|
|
k_dup = max(1, int(n * dup_frac))
|
|
dup_idx = rng.choice(n, size=min(k_dup, n), replace=False)
|
|
for j in dup_idx:
|
|
reviews[int(j)] = _DUP_REVIEW
|
|
|
|
# Nulos MCAR-ish: una fraccion de filas al azar queda en None.
|
|
if n > 0 and null_frac > 0:
|
|
k_null = max(1, int(n * null_frac))
|
|
null_idx = rng.choice(n, size=min(k_null, n), replace=False)
|
|
for j in null_idx:
|
|
reviews[int(j)] = None
|
|
|
|
return reviews
|
|
|
|
|
|
def _make_phone_intl(rng):
|
|
"""Construye un telefono en formato internacional que casa phone_intl.
|
|
|
|
Regex objetivo (fullmatch): ``\\+\\d[\\d\\s()-]{6,}\\d``. Empieza por '+',
|
|
digito, bloques de digitos separados por espacios y termina en digito.
|
|
"""
|
|
cc = int(rng.integers(1, 99))
|
|
a = int(rng.integers(100, 999))
|
|
b = int(rng.integers(100, 999))
|
|
c = int(rng.integers(100, 999))
|
|
return f"+{cc} {a} {b} {c}"
|
|
|
|
|
|
def _make_latlon(countries, rng):
|
|
"""Devuelve (latitudes, longitudes) muestreando centroides de pais + jitter.
|
|
|
|
Mantiene los valores dentro de [-90, 90] y [-180, 180] (validez exigida por
|
|
detect_latlon_columns). El jitter es pequeno para no salirse del rango.
|
|
"""
|
|
import numpy as np
|
|
|
|
lats = np.empty(len(countries), dtype=float)
|
|
lons = np.empty(len(countries), dtype=float)
|
|
jitter_lat = rng.normal(0.0, 0.5, len(countries))
|
|
jitter_lon = rng.normal(0.0, 0.5, len(countries))
|
|
for i, code in enumerate(countries):
|
|
base_lat, base_lon = _CENTROIDS[code]
|
|
lats[i] = float(np.clip(base_lat + jitter_lat[i], -90.0, 90.0))
|
|
lons[i] = float(np.clip(base_lon + jitter_lon[i], -180.0, 180.0))
|
|
return lats, lons
|
|
|
|
|
|
def _amount_with_outliers(n, rng, n_extreme=6, factor=50.0):
|
|
"""Serie lognormal de cola pesada con ~``n_extreme`` outliers altos (x``factor``)."""
|
|
import numpy as np
|
|
|
|
amount = rng.lognormal(mean=4.0, sigma=1.0, size=n)
|
|
if n > 0 and n_extreme > 0:
|
|
idx = rng.choice(n, size=min(n_extreme, n), replace=False)
|
|
amount[idx] = amount[idx] * factor
|
|
return amount
|
|
|
|
|
|
def generate_synthetic_eda_table(
|
|
out_db_path, table="synthetic", n_rows=2000, seed=42
|
|
):
|
|
"""Genera una tabla DuckDB sintetica que activa el maximo de capitulos del EDA.
|
|
|
|
Construye un DataFrame de ``n_rows`` clientes unicos con columnas elegidas para
|
|
disparar detectores concretos del motor AutomaticEDA (numericas continuas con
|
|
correlaciones lineal/no-lineal, numericas con outliers, categoricas
|
|
desbalanceadas, texto libre multi-idioma con duplicados, fecha para serie
|
|
temporal, lat/lon validas, semanticos/PII y nulos con patron MCAR/MAR), y la
|
|
materializa en ``out_db_path`` con ``CREATE OR REPLACE TABLE``.
|
|
|
|
Funcion impura (escribe a disco) y determinista por ``seed``: con el mismo
|
|
seed la tabla resultante es identica byte a byte. NUNCA lanza.
|
|
|
|
Args:
|
|
out_db_path: ruta al archivo DuckDB de salida. Se crea (o reutiliza) y la
|
|
tabla se reemplaza si ya existe.
|
|
table: nombre de la tabla a crear. Se valida contra
|
|
``^[A-Za-z_][A-Za-z0-9_]*$`` y se cita en el DDL.
|
|
n_rows: numero de filas (clientes unicos). Default 2000.
|
|
seed: semilla para Faker y numpy. Default 42.
|
|
|
|
Returns:
|
|
dict dict-no-throw. En exito::
|
|
|
|
{"status": "ok", "db_path": out_db_path, "table": table,
|
|
"n_rows": n_rows, "columns": [<nombres de columna>], "seed": seed}
|
|
|
|
En error (sin lanzar)::
|
|
|
|
{"status": "error", "error": str}
|
|
"""
|
|
try:
|
|
import duckdb
|
|
import numpy as np
|
|
import pandas as pd
|
|
|
|
if not _IDENT_RE.match(table or ""):
|
|
return {
|
|
"status": "error",
|
|
"error": (
|
|
f"nombre de tabla invalido: {table!r} "
|
|
"(debe casar con ^[A-Za-z_][A-Za-z0-9_]*$)"
|
|
),
|
|
}
|
|
n = int(n_rows)
|
|
if n <= 0:
|
|
return {"status": "error", "error": f"n_rows debe ser > 0, dado {n_rows!r}"}
|
|
|
|
fakers = _make_fakers(seed)
|
|
rng = np.random.default_rng(seed)
|
|
|
|
# --- Numericas continuas (distinct alto, correlaciones) ---
|
|
income = np.clip(rng.normal(40000.0, 12000.0, n), 1000.0, None)
|
|
spending = income * 0.35 + rng.normal(0.0, 2000.0, n) # corr POSITIVA fuerte
|
|
age = rng.integers(18, 91, n)
|
|
risk_score = 90.0 - age * 0.7 + rng.normal(0.0, 5.0, n) # corr NEGATIVA con age
|
|
tenure_months = rng.uniform(0.0, 60.0, n)
|
|
engagement_quad = ((tenure_months - 30.0) ** 2) / 30.0 + rng.normal(0.0, 1.0, n)
|
|
|
|
# --- Numericas con outliers claros ---
|
|
amount = _amount_with_outliers(n, rng)
|
|
n_purchases = rng.poisson(3.0, n).astype(float)
|
|
if n > 0:
|
|
k_hi = min(max(1, int(n * 0.002)) + 2, n) # ~3-5 valores altisimos
|
|
hi_idx = rng.choice(n, size=k_hi, replace=False)
|
|
n_purchases[hi_idx] = rng.integers(200, 400, len(hi_idx)).astype(float)
|
|
|
|
# --- Categoricas ---
|
|
country = rng.choice(_COUNTRIES, n)
|
|
category = rng.choice(_CATEGORIES, n)
|
|
plan = rng.choice(_PLANS, n, p=_PLAN_PROBS)
|
|
|
|
# --- Texto libre multi-idioma con duplicados ---
|
|
review = _make_reviews(n, rng, fakers)
|
|
|
|
# --- Fecha / serie temporal (rango ~2 anios, cadencia ~diaria) ---
|
|
base = np.datetime64("2022-01-01")
|
|
offsets = rng.integers(0, 730, n)
|
|
signup_date = pd.to_datetime(base) + pd.to_timedelta(offsets, unit="D")
|
|
|
|
# --- Geo lat/lon validas ---
|
|
latitude, longitude = _make_latlon(country, rng)
|
|
|
|
# --- Semanticos / PII (>=80% match para infer_semantic_type) ---
|
|
customer_id = [fakers["en_US"].uuid4() for _ in range(n)]
|
|
email = [fakers["en_US"].email() for _ in range(n)]
|
|
iban = [fakers["en_US"].iban() for _ in range(n)]
|
|
phone = [_make_phone_intl(rng) for _ in range(n)]
|
|
|
|
df = pd.DataFrame(
|
|
{
|
|
"customer_id": customer_id,
|
|
"email": email,
|
|
"iban": iban,
|
|
"phone": phone,
|
|
"income": income,
|
|
"spending": spending,
|
|
"age": age,
|
|
"risk_score": risk_score,
|
|
"tenure_months": tenure_months,
|
|
"engagement_quad": engagement_quad,
|
|
"amount": amount,
|
|
"n_purchases": n_purchases,
|
|
"country": country,
|
|
"category": category,
|
|
"plan": plan,
|
|
"review": review,
|
|
"signup_date": signup_date,
|
|
"latitude": latitude,
|
|
"longitude": longitude,
|
|
}
|
|
)
|
|
|
|
# --- Nulos con patron ---
|
|
# income + spending faltan JUNTAS en las MISMAS filas (co-ocurrencia -> MAR).
|
|
k_co = max(1, int(n * 0.12))
|
|
co_idx = rng.choice(n, size=min(k_co, n), replace=False)
|
|
df.loc[co_idx, "income"] = np.nan
|
|
df.loc[co_idx, "spending"] = np.nan
|
|
# risk_score falta cuando plan == "alta" (mas una pizca de azar) -> MAR.
|
|
risk_mask = (df["plan"] == "alta").to_numpy() | (rng.random(n) < 0.02)
|
|
df.loc[risk_mask, "risk_score"] = np.nan
|
|
|
|
columns = list(df.columns)
|
|
|
|
con = duckdb.connect(out_db_path)
|
|
try:
|
|
con.register("df_synth_eda", df)
|
|
con.execute(
|
|
f'CREATE OR REPLACE TABLE "{table}" AS SELECT * FROM df_synth_eda'
|
|
)
|
|
con.unregister("df_synth_eda")
|
|
finally:
|
|
con.close()
|
|
|
|
return {
|
|
"status": "ok",
|
|
"db_path": out_db_path,
|
|
"table": table,
|
|
"n_rows": n,
|
|
"columns": columns,
|
|
"seed": seed,
|
|
}
|
|
except Exception as exc: # noqa: BLE001 — dict-no-throw del grupo eda.
|
|
return {"status": "error", "error": str(exc)}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import json
|
|
import sys
|
|
|
|
args = sys.argv[1:]
|
|
db_path = args[0] if len(args) > 0 else "/tmp/synthetic_eda.duckdb"
|
|
tbl = args[1] if len(args) > 1 else "synthetic"
|
|
rows = int(args[2]) if len(args) > 2 else 2000
|
|
sd = int(args[3]) if len(args) > 3 else 42
|
|
print(json.dumps(generate_synthetic_eda_table(db_path, tbl, rows, sd), indent=2))
|