"""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": [], "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))