One language carries data from raw leaf litter on the forest floor to sunlight at the top of the tree. Today we learn Python from first principles, use it to train an ML model, build a data pipeline around that model, and scale the whole thing with Spark.
Python won data work for one reason: it reads like the pseudocode you'd scribble on a whiteboard. No semicolons, no type declarations to start, and indentation is the syntax — the visual structure of the code is its logic.
Why banks and funds standardised on itPython is the connective tissue between quants, data scientists, and engineers. The same language writes an exploratory notebook, a production model, and the pipeline that feeds it — which is exactly the journey this session takes.
Variables & types — labels, not boxes
A variable in Python is a name tag tied to an object. You never declare types; Python infers them at runtime (it's dynamically typed). Press Run on every cell — outputs are real.
01_variables.py
# Variables: assign with = , type is inferred
client_name ="Thaddeus"# str
portfolio_value =1_250_000.50# float (underscores for readability)
num_accounts =3# int
is_accredited =True# boolprint(type(portfolio_value))
print(f"{client_name} holds S${portfolio_value:,.2f} across {num_accounts} accounts")
Output<class 'float'>
Thaddeus holds S$1,250,000.50 across 3 accounts
That f"..." is an f-string — Python's way of weaving variables into text. The :,.2f part is a format spec: thousands separators, 2 decimal places. You will use f-strings every single day.
Control flow — decisions and repetition
Two workhorses: if / elif / else for branching, and for loops for iteration. Notice there are no braces — the indented block is the body.
02_control_flow.py
aum_sgd = [180_000, 950_000, 4_200_000, 60_000_000]
for aum in aum_sgd:
if aum >=30_000_000:
segment ="UHNW"elif aum >=1_500_000:
segment ="HNW"elif aum >=300_000:
segment ="Affluent"else:
segment ="Mass"print(f"S${aum:>12,} → {segment}")
This slider drives the exact if / elif ladder above. Move it and watch the branch that fires.
Functions — name a piece of logic once
Functions are defined with def. Type hints (aum: float) are optional but professional teams use them — they document intent and let tools catch bugs.
03_functions.py
defclassify_segment(aum: float) ->str:
"""Map assets under management to a wealth segment."""if aum >=30_000_000: return"UHNW"if aum >=1_500_000: return"HNW"if aum >=300_000: return"Affluent"return"Mass"# Functions are values too — pass them around like data
segments = [classify_segment(x) for x in (50_000, 2_000_000, 45_000_000)]
print(segments)
Output['Mass', 'HNW', 'UHNW']
Checkpoint · What defines a code block in Python?
Indentation — usually 4 spaces. It's not a style choice; it's the grammar. Misaligned code is broken code, which is why Python code from any team tends to look the same.
Part 2 · Growth
Data structures & the Pythonic idiom
Four built-in containers cover 95% of daily work. Choosing the right one is half of writing good Python.
Structure
Syntax
Ordered?
Mutable?
Reach for it when…
list
[1, 2, 3]
Yes
Yes
An ordered sequence — trades, rows, files
tuple
(lat, lon)
Yes
No
A fixed record that must not change
dict
{"key": val}
Yes*
Yes
Lookups by key — a client profile, JSON, config
set
{1, 2, 3}
No
Yes
Uniqueness & membership — dedupe, "have we seen this ID?"
* dicts preserve insertion order since Python 3.7 — handy, but don't build logic on it.
04_structures.py
# A dict is how structured data usually arrives (think: JSON from an API)
client = {
"id": "C-1042",
"name": "Thaddeus",
"holdings": ["VWRA", "IWDA", "SGD_CASH", "IWDA"], # oops, a dupe
}
unique_holdings =set(client["holdings"]) # dedupe in one moveprint(f"{client['name']} holds {len(unique_holdings)} distinct assets: {sorted(unique_holdings)}")
A list comprehension builds a new list by transforming and filtering another, in one readable line. It's the idiom that most clearly separates "writing Python" from "writing Java in Python". It's also conceptually a map + filter — remember that phrase, because Spark is built on the same two verbs.
05_comprehensions.py
balances_sgd = [120_000, -500, 2_400_000, 0, 88_000]
# The loop way (4 lines)…
usd = []
for b in balances_sgd:
if b >0:
usd.append(round(b /1.34, 2))
# …and the comprehension way (1 line): [transform for item in source if filter]
usd2 = [round(b /1.34, 2) for b in balances_sgd if b >0]
print(usd == usd2, usd2)
OutputTrue [89552.24, 1791044.78, 65671.64]
Classes, errors, and files — the last three essentials
06_essentials.py
from dataclasses import dataclass
# A dataclass: a class that's mostly data — Python writes the boilerplate@dataclassclassTrade:
symbol: str
qty: int
price: floatdefnotional(self) ->float:
return self.qty * self.price
t = Trade("VWRA", 150, 142.30)
print(t, "→ notional:", t.notional())
# Errors: catch what you expect, let the rest crash loudlytry:
bad =float("n/a")
exceptValueErroras e:
print("Handled:", e)
OutputTrade(symbol='VWRA', qty=150, price=142.3) → notional: 21345.0
Handled: could not convert string to float: 'n/a'
Checkpoint · You're ingesting client IDs and must reject any you've already seen. Best structure for the "seen" collection?
A set checks "is this ID already here?" in constant time regardless of size. A list would scan every element — fine for 100 IDs, painful for 10 million. Data engineering is largely the art of caring about this difference.
Part 3 · Canopy Lab
Data science: from DataFrame to ML model
Data science in Python rests on a stack of libraries, each one layer up the tree: NumPy (fast arrays) → pandas (tables) → scikit-learn (classical ML) → PyTorch / TensorFlow (deep learning). You'll spend most of your life in the middle two.
pandas — the spreadsheet you script
The core object is the DataFrame: a table with named columns, like a SQL table or Excel sheet you command with code. The universal data science loop is load → inspect → clean → transform → model.
07_pandas_eda.py
import pandas as pd
df = pd.read_csv("clients.csv") # loadprint(df.head(3)) # inspect: first rows
df = df.dropna(subset=["age", "aum"]) # clean: drop missing
df["engaged"] = df["logins_90d"] >=6# transform: new feature# The single most useful pattern in analytics: groupby → aggregateprint(df.groupby("segment")["aum"].agg(["count", "mean"]).round(0))
Output id age segment aum logins_90d
0 C-1001 34 Mass 112000 9
1 C-1002 51 HNW 2450000 2
2 C-1003 45 Affluent 610000 11
count mean
segment
Affluent 412 680,000.0
HNW 188 3,900,000.0
Mass 1400 130,000.0
UHNW 23 52,000,000.0
Mental modelgroupby("segment")["aum"].mean() in pandas, GROUP BY segment in SQL, and df.groupBy("segment").avg("aum") in PySpark are the same idea in three dialects. Learn the idea once; the syntax follows.
Training a model with scikit-learn
Every scikit-learn model — regression, random forest, gradient boosting — follows the same four-step ritual. This is a real example: predicting a client's propensity to accept an advisory nudge.
08_train_model.py
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import roc_auc_score
X = df[["age", "aum", "logins_90d", "tenure_yrs"]] # features
y = df["accepted_nudge"] # label (0/1)# 1. Split — hold out data the model never sees, to test honestly
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.2, random_state=42)
model = RandomForestClassifier(n_estimators=300) # 2. Choose
model.fit(X_tr, y_tr) # 3. Fit (learn)
probs = model.predict_proba(X_te)[:, 1] # 4. Predictprint(f"AUC on unseen clients: {roc_auc_score(y_te, probs):.3f}")
OutputAUC on unseen clients: 0.847
Playground: teach a model by hand
Click (or tap) to drop data points — say, engagement score vs AUM. The line refits instantly using least squares, the exact math inside LinearRegression().fit(X, y). Watch R² judge your data's messiness.
Drop at least 2 points to fit a line.
Playground: the train / test split
Why hold data back? Because a model graded on its own homework always looks brilliant. Slide to change the split and see how the honest score behaves.
Where deep learning fits
scikit-learn covers tabular problems — churn, propensity, credit scoring. When the data is unstructured (text, images, speech), you climb to PyTorch or TensorFlow, where models are stacks of layers trained by gradient descent on GPUs. The Python you write barely changes — it's still objects, functions, and loops — only the library and the compute bill grow.
Checkpoint · Your model scores 0.99 on training data and 0.61 on the held-out test set. What's happening?
A big train–test gap is the classic overfitting signature. The fix: simpler models, more data, regularisation, or better features. The test set exists precisely to catch this before your clients do.
Part 4 · The River
Data engineering: building the pipeline
A model is only as good as the water flowing to it. Data engineering is the river system: moving data from source systems into shapes the business — and the model — can drink from. The universal pattern is ETL/ELT: Extract, Transform, Load.
The medallion architecture — data's climb through the forest
Modern lakehouse platforms (Databricks, Delta Lake, Iceberg) organise data into three refinement layers. Think of it as light moving up through forest strata. Click each stratum to watch one real record climb.
Bronze · Forest floor
Raw
Data lands exactly as the source system produced it — duplicates, nulls, typos and all. Append-only, never edited. Why keep the mess? Because it's your audit trail and replay button: if downstream logic was wrong, you rebuild everything from here.
Deduplicated, typed, validated, joined with reference data. One row per real-world entity. This is the layer data scientists trust for feature engineering — clean enough to use, granular enough to be flexible.
Aggregated, business-named marts: AUM by segment by month, nudge acceptance by channel. This is what dashboards, regulators, and executives touch. Sunlight — refined, expensive, and worth the climb.
Before frameworks, understand that a pipeline is just functions composed in order. Here's bronze → silver → gold in ~25 lines. This exact shape scales up: swap pandas for PySpark and the structure survives.
Press each stage in order. Watch the record count fall as quality gates bite, and note the file format changing — JSON at the floor, Parquet in the canopy.
[river] idle — press Extract to begin
Orchestration — who runs the river on schedule?
In production, an orchestrator (Airflow, Dagster, Prefect — all Python) runs your pipeline on a schedule, retries failures, and alerts on breaks. You describe the pipeline as a DAG — a directed acyclic graph of tasks. Hold that word: Spark uses the very same concept one level down.
10_airflow_dag.py
from airflow.decorators import dag, task
from datetime import datetime
@dag(schedule="0 6 * * *", start_date=datetime(2026, 1, 1), catchup=False)
defwealth_daily():
@taskdefbronze(): ... # land raw events@taskdefsilver(): ... # clean & conform@taskdefgold(): ... # aggregate marts@taskdefscore_clients(): ... # run the Part-3 model on fresh silver data
bronze() >> silver() >> [gold(), score_clients()] # dependencies
wealth_daily()
Checkpoint · A regulator questions a number in a gold-layer report from March. What makes this answerable?
This is the whole point of an append-only bronze layer: lineage and reproducibility. In a bank, "we can replay it" is not a nice-to-have — it's an audit requirement.
Part 5 · The Storm
Spark & PySpark: when one machine isn't enough
pandas holds your data in one machine's memory. At 10 GB it groans; at 500 GB it dies. Apache Spark's answer: chop the data into partitions, spread them across a cluster of machines, and run your logic on all of them at once. PySpark is the Python steering wheel for that cluster.
The cast of characters
Driver
The one machine running your Python script. It plans the work, builds the DAG, and collects results. The conductor — it holds the score, not the instruments.
Executors
Worker processes on cluster machines that hold partitions in memory and execute tasks on them in parallel. The orchestra.
Partitions
The chunks your dataset is split into. 500 GB might become 4,000 partitions of ~128 MB. Parallelism = how many partitions can be worked at once.
Shuffle
When an operation like groupBy needs rows with the same key on the same machine, data moves across the network. The expensive monsoon — necessary, but you minimise it.
Playground: the cluster at work
A 96-partition job. Scale the cluster and run it — each executor works its partitions in parallel (gold = processing, mint = done). Watch wall-clock time change. This is what "horizontal scaling" means.
1 executor
[cluster] idle
Lazy evaluation — Spark's superpower
In pandas, every line executes immediately. In Spark, transformations (filter, select, groupBy) only describe work — they build a DAG and nothing runs. Only an action (count, collect, write) triggers execution. Why? It lets Spark see the whole plan and optimise it — pushing filters early, pruning unused columns, minimising shuffles — before touching a single byte.
Playground: build a plan, then light the fuse
Click transformations in any order — notice the cluster does nothing. Then click an action.
DAG: (empty — add transformations)
[spark] nothing executed — transformations are lazy
PySpark in practice — pandas déjà vu
Here is the Part-4 pipeline rewritten for a cluster. Squint and it's the same code — that's deliberate. The DataFrame API is a lingua franca.
11_pyspark_pipeline.py
from pyspark.sql import SparkSession, functions as F
spark = SparkSession.builder.appName("wealth_gold").getOrCreate()
events = spark.read.json("s3://lake/bronze/events/") # 500 GB? fine.
silver = (events
.dropDuplicates(["cust_id", "ts"])
.withColumn("cust_id", F.upper(F.trim("cust_id")))
.withColumn("aum_sgd", F.regexp_replace("aum", ",", "").cast("double"))
.filter(F.col("aum_sgd") >=0)) # all lazy so far…
gold = silver.groupBy("segment").agg(
F.countDistinct("cust_id").alias("clients"),
F.sum("aum_sgd").alias("total_aum"))
gold.write.mode("overwrite").parquet("s3://lake/gold/aum_by_segment") # ACTION → now it runs
Output[stage 0] read bronze: 3,912 partitions · 498.2 GB
[stage 1] dedupe + clean: 3,912 tasks ............ done (no shuffle)
[stage 2] groupBy segment: shuffle 4 partitions .. done (1 shuffle)
[write] gold/aum_by_segment · 4 rows
Job finished in 4m 12s on 40 executors — pandas would need a ~500 GB machine
When NOT to use SparkIf your data fits in one machine's memory (say, under ~10–50 GB), pandas or DuckDB will be simpler and faster — no cluster startup, no shuffle, no ops overhead. Spark's power has a coordination tax. Reach for the storm only when the forest is actually on fire.
Checkpoint · You run df.filter(...).groupBy(...).agg(...) in PySpark and it returns instantly on 2 TB of data. Why?
filter / groupBy / agg only build the plan. Add .count(), .show() or .write and the DAG actually executes — that's when you wait.
Part 6 · Field Guide
Compute, layers, and your trail map home
Compute is the CPU/GPU + memory that executes your code — as opposed to storage, where data rests. The defining move of the modern stack is separating the two: data lives cheaply in object storage (S3, ADLS, GCS); compute spins up, drinks from it, and shuts down.
Vertical scaling
Buy a bigger machine. Simple, but there's a ceiling — and one very expensive tree can still fall over.
Horizontal scaling
Add more machines — the Spark way. Near-limitless, resilient, but you pay a coordination (shuffle) tax. You watched this trade-off in the cluster playground.
The whole forest on one page
Layer
What lives there
Python tool
Who works here
Bronze
Raw, immutable events & extracts
spark.read.json / kafka
Data engineers
Silver
Clean, typed, deduped entities
PySpark / pandas transforms
Data engineers + scientists
Gold
Aggregated business marts & features
groupBy → parquet / SQL
Analysts, ML models, dashboards
Model
Trained artefacts scoring fresh data
scikit-learn / PyTorch
Data scientists + ML engineers
Orchestration
Schedules, retries, lineage
Airflow / Dagster DAGs
Data engineers
Cheat sheet — one idea, three dialects
Idea
Python
pandas
PySpark
Filter
[x for x in xs if p(x)]
df[df.aum > 0]
df.filter(F.col("aum") > 0)
Transform
[f(x) for x in xs]
df["usd"] = df.sgd / 1.34
df.withColumn("usd", ...)
Aggregate
sum(xs)
df.groupby("seg").sum()
df.groupBy("seg").sum()
Execute
immediately
immediately
only on an action (.count())
Your trail from here
Week 1–2 · Roots
Install Python + VS Code. Rewrite one Excel task as a script using lists, dicts, functions, f-strings.
Week 3–4 · Growth
pandas on a real CSV from your world: load, clean, groupby, chart with matplotlib.
Month 2 · Canopy
Train a scikit-learn classifier end-to-end. Split honestly, measure AUC, explain overfitting to a colleague.
Month 3 · The River & Storm
Build a bronze→silver→gold pipeline in pandas, schedule it with Airflow, then port one stage to PySpark on Databricks Community Edition (free).
The one-sentence takeawayPython is a single trail that runs the whole forest: the same language explores data at the floor, trains models in the canopy, and commands the storm when the data outgrows a single tree.