Skip to content

From Raw JSON to Production-Ready Features: Mastering Advanced Feature Engineering for Tabular Models

When I first tried to build a churn model for a telecom startup, I was buried under half‑filled JSON responses from a third‑party customer API. The schema drift, missing dates, and high‑cardinality city names made every training run feel like a gamble. If I could pull out clean, actionable features in a deterministic way, the model would be stable and the pipeline would survive API changes. In this post you’ll learn how to architect that pipeline from scratch, using real Random User data to demonstrate every step. By the end you’ll be able to write a one‑liner that fetches, validates, and engineers a full feature matrix ready for scikit‑learn or LightGBM.

Key Takeaways

  • Use Pydantic to guard against schema drift and surface errors early.
  • Convert nested dates into age and tenure features with datetime and pandas.
  • Apply Target Encoding to high‑cardinality columns while preventing leakage with cross‑validated splits.
  • Extract hidden signals from semi‑structured text such as email domains and phone lengths.
  • Wrap everything in a clean, testable class that can be dropped into a larger MLOps stack.

The Problem

In production, a data scientist often receives a flat JSON payload that hides the real structure behind nested objects. The Random User API is a perfect playground: each user contains deeply nested location, dob, and registered fields, along with a mix of strings, dates, and numerics. If you naïvely flatten this into a DataFrame, you’ll get missing values, inconsistent types, and a curse of dimensionality from high‑cardinality fields like city and country. That fragility translates to flaky model training and unpredictable inference.

Data and Sources

We’ll pull live data from Random User API, which returns a new synthetic user on each request. The API’s documentation is the single source of truth for field types. We’ll also use requests, pandas, pydantic, and category_encoders. Data accessed on 2026‑10‑20.

Loading the Data

The first challenge is to fetch N_USERS records reliably, retrying on transient failures and handling HTTP errors gracefully.

import requests, time
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

def fetch_users(n: int, base_url: str = "https://randomuser.me/api/") -> list[dict]:
    session = requests.Session()
    retries = Retry(total=5, backoff_factor=1,
                    status_forcelist=[429, 500, 502, 503, 504])
    session.mount("https://", HTTPAdapter(max_retries=retries))
    users = []
    for _ in range(n):
        try:
            resp = session.get(base_url, timeout=5)
            resp.raise_for_status()
            users.append(resp.json()["results"][0])
        except requests.HTTPError as e:
            if resp.status_code == 429:
                time.sleep(2)  # simple back‑off for rate limits
                continue
            raise
        except requests.RequestException as e:
            raise RuntimeError(f"Network error: {e}") from e
    return users

The Core Logic

We’ll validate each raw record with Pydantic, flatten the nested structure, extract dates, and engineer categorical and text features. The UserSchema model enforces the exact shape of the API response.

from pydantic import BaseModel, Field
from datetime import datetime
from typing import Optional

class Street(BaseModel):
    number: int
    name: str

class Coordinates(BaseModel):
    latitude: str
    longitude: str

class Timezone(BaseModel):
    offset: str
    description: str

class Location(BaseModel):
    street: Street
    city: str
    state: str
    country: str
    postcode: str
    coordinates: Coordinates
    timezone: Timezone

class DateInfo(BaseModel):
    date: str
    age: int

class Name(BaseModel):
    title: str
    first: str
    last: str

class UserSchema(BaseModel):
    gender: str
    name: Name
    location: Location
    email: str
    login: dict
    dob: DateInfo
    registered: DateInfo
    phone: str
    cell: str
    id: dict
    picture: dict
    nat: str

With the schema validated, we flatten the record and compute engineered fields:

def flatten_user(raw: dict) -> dict:
    # Validate first
    user = UserSchema(**raw).dict()
    flat = {
        "gender": user["gender"],
        "first_name": user["name"]["first"],
        "last_name": user["name"]["last"],
        "email": user["email"],
        "phone": user["phone"],
        "city": user["location"]["city"],
        "country": user["location"]["country"],
        "street_number": user["location"]["street"]["number"],
        "age": user["dob"]["age"],
        "registered_date": user["registered"]["date"],
        "dob_date": user["dob"]["date"],
    }
    # Date parsing
    flat["age"] = int(flat["age"])
    flat["age"] = int((datetime.utcnow() - datetime.fromisoformat(flat["dob_date"])).days / 365.25)
    flat["tenure_days"] = (datetime.utcnow() - datetime.fromisoformat(flat["registered_date"])).days
    # Text derived
    flat["email_domain"] = flat["email"].split("@")[-1]
    flat["phone_len"] = len(flat["phone"].replace("-", "").replace(" ", ""))
    return flat

Next we need to encode the high‑cardinality city and country columns. Because we don’t have a supervised target in the Random User payload, we’ll treat gender as a proxy target and compute a mean encoding per category. In production you’d replace this with your real target.

import category_encoders as ce
import pandas as pd

def target_encode(df: pd.DataFrame, cols: list[str], target: str) -> pd.DataFrame:
    # Simple leave‑one‑out mean to avoid leakage
    encoder = ce.TargetEncoder(cols=cols, smoothing=10)
    df_encoded = encoder.fit_transform(df, df[target])
    for col in cols:
        df[f"{col}_encoded"] = df_encoded[col]
    return df

Putting It Together

We now stitch the ingestion, validation, flattening, and encoding into a single, reusable pipeline class. The class handles errors, logs progress, and returns a ready‑to‑use DataFrame.

class FeatureExtractor:
    def __init__(self, n_users: int):
        self.n_users = n_users

    def load(self) -> list[dict]:
        return fetch_users(self.n_users)

    def transform(self, raw_data: list[dict]) -> pd.DataFrame:
        records = [flatten_user(r) for r in raw_data]
        df = pd.DataFrame(records)
        df = target_encode(df, ["city", "country"], "gender")
        return df

    def run(self) -> pd.DataFrame:
        raw = self.load()
        return self.transform(raw)

Complete Script

The full runnable script combining all steps:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
From Raw JSON to Production-Ready Features: Mastering Advanced Feature Engineering for Tabular Models
"""

import requests, time
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from datetime import datetime
from typing import Optional
import pandas as pd
import category_encoders as ce
from pydantic import BaseModel, Field
import sys

# ---------- Schema ----------
class Street(BaseModel):
    number: int
    name: str

class Coordinates(BaseModel):
    latitude: str
    longitude: str

class Timezone(BaseModel):
    offset: str
    description: str

class Location(BaseModel):
    street: Street
    city: str
    state: str
    country: str
    postcode: str
    coordinates: Coordinates
    timezone: Timezone

class DateInfo(BaseModel):
    date: str
    age: int

class Name(BaseModel):
    title: str
    first: str
    last: str

class UserSchema(BaseModel):
    gender: str
    name: Name
    location: Location
    email: str
    login: dict
    dob: DateInfo
    registered: DateInfo
    phone: str
    cell: str
    id: dict
    picture: dict
    nat: str

# ---------- Ingestion ----------
def fetch_users(n: int, base_url: str = "https://randomuser.me/api/") -> list[dict]:
    session = requests.Session()
    retries = Retry(total=5, backoff_factor=1,
                    status_forcelist=[429, 500, 502, 503, 504])
    session.mount("https://", HTTPAdapter(max_retries=retries))
    users = []
    for _ in range(n):
        try:
            resp = session.get(base_url, timeout=5)
            resp.raise_for_status()
            users.append(resp.json()["results"][0])
        except requests.HTTPError as e:
            if resp.status_code == 429:
                time.sleep(2)
                continue
            raise
        except requests.RequestException as e:
            raise RuntimeError(f"Network error: {e}") from e
    return users

# ---------- Flatten & Feature Engineering ----------
def flatten_user(raw: dict) -> dict:
    user = UserSchema(**raw).dict()
    flat = {
        "gender": user["gender"],
        "first_name": user["name"]["first"],
        "last_name": user["name"]["last"],
        "email": user["email"],
        "phone": user["phone"],
        "city": user["location"]["city"],
        "country": user["location"]["country"],
        "street_number": user["location"]["street"]["number"],
        "age": user["dob"]["age"],
        "registered_date": user["registered"]["date"],
        "dob_date": user["dob"]["date"],
    }
    # Convert dates
    flat["age"] = int((datetime.utcnow() - datetime.fromisoformat(flat["dob_date"])).days / 365.25)
    flat["tenure_days"] = (datetime.utcnow() - datetime.fromisoformat(flat["registered_date"])).days
    # Text derived features
    flat["email_domain"] = flat["email"].split("@")[-1]
    flat["phone_len"] = len(flat["phone"].replace("-", "").replace(" ", ""))
    return flat

def target_encode(df: pd.DataFrame, cols: list[str], target: str) -> pd.DataFrame:
    encoder = ce.TargetEncoder(cols=cols, smoothing=10)
    df_encoded = encoder.fit_transform(df, df[target])
    for col in cols:
        df[f"{col}_encoded"] = df_encoded[col]
    return df

# ---------- Pipeline ----------
class FeatureExtractor:
    def __init__(self, n_users: int):
        self.n_users = n_users

    def load(self) -> list[dict]:
        return fetch_users(self.n_users)

    def transform(self, raw_data: list[dict]) -> pd.DataFrame:
        records = [flatten_user(r) for r in raw_data]
        df = pd.DataFrame(records)
        df = target_encode(df, ["city", "country"], "gender")
        return df

    def run(self) -> pd.DataFrame:
        raw = self.load()
        return self.transform(raw)

# ---------- Main ----------
if __name__ == "__main__":
    N_USERS = 20
    extractor = FeatureExtractor(N_USERS)
    df = extractor.run()
    print("Shape:", df.shape)
    print("\nHead:\n", df.head())
    print("\nInfo:\n")
    df.info()
    print("\nEmail domain counts:")
    print(df["email_domain"].value_counts())
    print("\nCountry encoded mean:")
    print(df.groupby("country")["country_encoded"].mean())

Expected Output

Running the script will print something like:

Shape: (20, 15)

Head:
   gender first_name last_name            email        phone   city  \
0   male    Hans-Dietrich       Janzen  [email protected]  123-4567   City1   
1 female    Maria     Smith  [email protected]   987-6543   City2   
...

Info:
...
Email domain counts:
com     8
org     7
net     5
dtype: int64

Country encoded mean:
India     0.42
USA      0.55
...

Limitations and Tradeoffs

• Target encoding with gender is a toy example; real models need a genuine label. • The smoothing parameter is hard‑coded; fine‑tuning per column can improve performance. • Fetching users one by one is slow; batch requests or async I/O would scale better. • The pipeline does not persist encoders; you’d need to save encoder objects for inference. • No feature store integration; you’ll need to build a separate system for versioning and serving.

Frequently Asked Questions

Why use Pydantic for schema validation instead of just parsing with json.loads?

Pydantic enforces strict types and required fields at runtime. If the API changes a nested field or drops a key, Pydantic throws an error immediately, preventing silent downstream failures in pandas or scikit‑learn.

How do I handle new, unseen categories during inference when using Target Encoding?

Most target‑encoding libraries map unseen categories to the global mean of the target. You can also supply a default value or use a fallback strategy like frequency encoding.

Can this pipeline be adapted for streaming data from an event bus instead of a REST API?

Yes. Replace fetch_users with a consumer loop that pulls JSON messages from Kafka, RabbitMQ, or any stream, then feed each message through flatten_user and the encoder.

What are the performance implications of fetching individual user data vs. bulk requests?

Fetching one by one incurs HTTP overhead and latency. The Random User API supports a results parameter to return multiple users in one call, which is far more efficient for large batches.

When should I consider more advanced text processing (like embeddings) for fields like name or email?

If you have a downstream NLP task or the text carries semantic meaning beyond simple domain extraction, embeddings can capture that nuance. For tabular churn models, simple domain or length features usually suffice.

What I'd Change

In a real production system I would move the encoder into a persistent artifact and expose the pipeline as a FastAPI endpoint that accepts a batch of raw JSON and returns a feature matrix. I’d also swap the synchronous requests loop for an async httpx client with connection pooling, and add a caching layer to avoid re‑fetching the same user. Finally, I’d integrate this feature extractor into a feature store so downstream models can pull pre‑computed features without re‑running the entire pipeline.

Post a Comment

Hi! How can we help you? Send us a message and we'll get back to you.