DEV Community

kirandeepjassal-crypto
kirandeepjassal-crypto

Posted on Originally published at prepstack.co.in

Python for AI/ML the Right Way: NumPy Vectorization, Pandas GroupBy, Matplotlib OO, Functional Patterns & Production API/JSON (Real Code)

Most "Python for ML" articles teach you import numpy as np and stop at toy [1,2,3] examples. Then you sit down with a real 4 GB CSV, a flaky vendor API, and a deadline — and none of those tutorials help.

This is the deep version: why NumPy is fast, why pandas iterates rows badly, why requests.get(url).json() is a production bug waiting to happen, and how functional patterns make pandas pipelines readable. Every section has runnable code. (Full version with all sections on the original — link at the end.)

TL;DR

  • NumPy is fast because it stores data in contiguous C arrays and runs operations in compiled loops — the Python for loop is exactly what you're escaping.
  • Pandas is NumPy plus labels plus joins plus time series. Master .loc, .groupby().agg(), and vectorised assignment; never iterrows().
  • Matplotlib has two APIs — the pyplot state machine (fine for notebooks) and the object-oriented fig, ax (use this in production). Most confusion comes from mixing them.
  • Functional patterns (comprehensions, functools, itertools, pure functions) make ML preprocessing readable and composable.
  • APIs + JSON are how data gets into your model. Always set timeouts, retry on transient errors, validate with Pydantic, and never trust the shape of upstream JSON.

1. Why NumPy is fast (the part no tutorial explains)

import numpy as np, time
py_list = list(range(1_000_000))              # each element is a full PyObject
np_arr  = np.arange(1_000_000, dtype=np.float64)  # one contiguous C buffer

# Add 1 to every element, two ways:
result_py = [x + 1 for x in py_list]   # ~62 ms
result_np = np_arr + 1                 # ~1.8 ms  -> ~35x faster
Enter fullscreen mode Exit fullscreen mode

That ~35x isn't magic. It's three things: contiguous memory (CPU caches love it), no Python objects in the loop (the +1 runs in compiled C), and SIMD (modern CPUs add 4–8 floats per cycle). The corollary: the moment you write a Python for loop over a NumPy array, you've thrown the speed away.

Broadcasting — the secret sauce

# Standardise (z-score) every column of a (1000, 3) dataset
X = np.random.default_rng(0).standard_normal((1000, 3))
X_z = (X - X.mean(axis=0)) / X.std(axis=0)   # broadcast (3,) across all rows
Enter fullscreen mode Exit fullscreen mode

Broadcasting lets NumPy combine arrays of different shapes without explicit loops. The real payoff is replacing nested loops with array math:

# Pairwise Euclidean distances between 1000 vectors in 100 dims
# Naive double for-loop: ~4.2 s. Vectorised: ~8 ms. ~500x faster.
def pairwise_fast(X):
    diff = X[:, None, :] - X[None, :, :]   # shape (n, n, d) via broadcasting
    return np.sqrt((diff ** 2).sum(axis=-1))
Enter fullscreen mode Exit fullscreen mode

That X[:, None, :] - X[None, :, :] trick — using None to add an axis — is the most idiomatic NumPy you'll write. It shows up everywhere.

2. Pandas — the data layer of ML

Read your data correctly. Specify dtypes, use usecols= to read only what you need, and parse_dates= to get real datetime64 columns:

df = pd.read_csv("transactions.csv",
    dtype={"user_id": "int64", "amount_cents": "int64",
           "merchant": "category", "country": "category"},
    parse_dates=["created_at"],
    usecols=["user_id", "amount_cents", "merchant", "country", "created_at"])
Enter fullscreen mode Exit fullscreen mode

Use .loc for everything that writes. Chained indexing (df[mask]["col"] = 0) triggers SettingWithCopyWarning and silent bugs. df.loc[mask, "col"] = 0 is correct.

GroupBy is the operation that pays the pandas tax back:

daily = (df.groupby([df["created_at"].dt.date, "country"])
           .agg(revenue   = ("amount_cents", "sum"),
                orders    = ("user_id", "count"),
                users     = ("user_id", "nunique"),
                avg_value = ("amount_cents", "mean"))
           .reset_index())
Enter fullscreen mode Exit fullscreen mode

Method chaining gives readable pipelines — five steps, no temp variables. The lambda d: pattern references the current intermediate DataFrame:

clean = (df
    .query("amount_cents > 0")
    .assign(amount      = lambda d: d["amount_cents"] / 100,
            day_of_week = lambda d: d["created_at"].dt.day_name(),
            is_weekend  = lambda d: d["created_at"].dt.dayofweek >= 5)
    .loc[lambda d: d["country"].isin(["IN", "US", "GB"])]
    .sort_values("created_at").reset_index(drop=True))
Enter fullscreen mode Exit fullscreen mode

Never iterrows(). It's ~100x the cost. Vectorise instead:

# Slow: for idx, row in df.iterrows(): ...
df["tax"] = np.where(df["country"] == "IN", df["amount"] * 0.18, 0.0)
Enter fullscreen mode Exit fullscreen mode

Time-series engineering is three ops — resample, rolling, shift — and always check the shape after a merge (accidental Cartesian joins silently produce wrong models).

3. Matplotlib — use the object-oriented API

Two APIs confuse everyone: pyplot (state machine, fine in notebooks) and the OO fig, ax (the only correct choice in production). Always use OO. The skeleton you'll write a thousand times:

fig, ax = plt.subplots(figsize=(10, 6))
ax.plot(x, np.sin(x), label="sin(x)")
ax.set_title("Trig"); ax.set_xlabel("x"); ax.legend(); ax.grid(True, alpha=0.3)
fig.tight_layout()
fig.savefig("trig.png", dpi=150, bbox_inches="tight")
Enter fullscreen mode Exit fullscreen mode

Create fig, ax; call methods on ax; finish with tight_layout + savefig. For multi-panel, index axes[row, col]. Pass ax=ax to Seaborn to keep the OO model.

4. Functional patterns — what makes ML code clean

You write transformations, not OOP. Comprehensions are Python's map/filter; functools.partial binds arguments; lru_cache memoizes pure functions; itertools.product is a hyperparameter grid; and pure functions compose, test, parallelise, and cache:

from functools import lru_cache, partial
from itertools import product

@lru_cache(maxsize=1024)
def embed(text: str) -> list[float]:
    return slow_embedding_api(text)

for lr, batch_size in product([1e-3, 1e-4], [16, 32, 64]):
    train(lr=lr, batch_size=batch_size)

# Pure: return a NEW DataFrame, don't mutate the input
def add_features(df):
    return df.assign(amount_log = lambda d: np.log1p(d["amount"]),
                     weekend    = lambda d: d["created_at"].dt.dayofweek >= 5)
Enter fullscreen mode Exit fullscreen mode

5. APIs — the boring part that breaks production at 2 AM

This line is a footgun: requests.get(url).json() — no timeout, throws on HTML responses, KeyErrors on shape change, no retry, no auth. The right stack is httpx + tenacity + Pydantic:

import httpx
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
from pydantic import BaseModel

class User(BaseModel):
    id: int; email: str; country: str | None = None

class UsersResponse(BaseModel):
    users: list[User]; next_cursor: str | None = None

@retry(stop=stop_after_attempt(4),
       wait=wait_exponential(multiplier=0.5, max=8),
       retry=retry_if_exception_type((httpx.HTTPStatusError, httpx.TimeoutException)))
def fetch_users(client, cursor):
    r = client.get("/v1/users", params={"limit": 100, "cursor": cursor}, timeout=10.0)
    r.raise_for_status()
    return UsersResponse.model_validate_json(r.content)
Enter fullscreen mode Exit fullscreen mode
  • httpx.Client(...) keeps the connection alive — 3–10x faster than a fresh requests.get() per call.
  • timeout=10.0 — never trust the upstream.
  • raise_for_status() turns 4xx/5xx into retryable exceptions.
  • Pydantic models parse JSON and validate types.

For high throughput, use httpx.AsyncClient with an asyncio.Semaphore to cap concurrency under the upstream's rate limit.

6. JSON — don't trust the shape

# Brittle: for item in o["items"]:   # KeyError if missing, TypeError if null
# Defensive:
for o in data["orders"]:
    for item in (o.get("items") or []):   # missing AND null both become []
        process(item)
Enter fullscreen mode Exit fullscreen mode

Better: validate at the boundary with Pydantic (Order.model_validate_json(raw) gives parsing, validation, type safety, and good errors in one call). For big datasets use JSONL (newline-delimited JSON) and stream line by line.

7. Putting it together — a real ML data pipeline

The full article ends with one runnable pipeline that uses every concept: a Pydantic + retry API client, read_parquet with columns=, .pipe() to compose pure functions, .groupby().agg(...) for per-user features, .merge() to join, and OO matplotlib for a visual check.

def build_training_table(users, tx):
    return (tx
        .pipe(add_time_features)
        .pipe(aggregate_per_user)
        .merge(users, left_on="user_id", right_on="id", how="left")
        .assign(account_age_days = lambda d: (pd.Timestamp.utcnow() - d["signup_date"]).dt.days)
        .drop(columns=["id", "signup_date"]))
Enter fullscreen mode Exit fullscreen mode

The mental model

Python in ML is a glue language. You're not optimising Python; you're composing fast C/CUDA/Rust kernels (NumPy, pandas, PyTorch, polars, faiss, vLLM). Whole-array NumPy ops, groupby().agg(), vectorised boolean masks — fast. for loops over rows, iterrows(), Python functions inside .apply() — slow. Internalise that one rule and you write idiomatic, fast Python by default.

Go write the pipeline. The model is the easy part.


This is a condensed version. The full guide — with every code block, the time-series and merge sections, async APIs, response caching, and the complete end-to-end pipeline — is on PrepStack:

→ Python for AI/ML — From Pandas Tricks to Production Pipelines

Top comments (0)