NumPy and pandas basics
Two libraries sit underneath almost all numerical AI work in Python. NumPy gives you fast arrays of numbers — exactly what embeddings, logits, and model weights are. pandas gives you labelled tables — exactly what datasets and evaluation results are. This lesson teaches both from the ground up and connects every feature to a real AI task, including building a tiny semantic search engine.
Install and import
python -m pip install numpy pandas
By universal convention, NumPy is imported as np and pandas as pd. You will see these aliases in every tutorial, notebook, and codebase.
import numpy as np
import pandas as pd
Part 1 — NumPy
Numerical Python: the foundational library for numeric computing. Its core is the ndarray, and its operations are implemented in optimised C, which makes them 10–100× faster than equivalent Python loops.
An N-dimensional array: a grid of values that all share one data type and are stored contiguously in memory. A 1-D array is a vector, a 2-D array is a matrix, and higher dimensions are often called tensors.
Why does this matter for AI? An embedding is a 1-D array of floats (often 384, 768, 1536, or 3072 numbers). A collection of embeddings is a 2-D matrix with one row per document. A batch of images is a 4-D array. Every one of these is an ndarray or a close cousin like a PyTorch tensor, which deliberately copies NumPy's interface.
Creating arrays
import numpy as np
v = np.array([0.2, 0.5, 0.8]) # from a Python list
m = np.array([[1, 2, 3], [4, 5, 6]]) # 2-D: list of lists
print(v)
print(m)
print(np.zeros(3), np.ones((2, 2)).tolist())
print(np.arange(0, 10, 3)) # like range(), as an array
print(np.linspace(0, 1, 5)) # 5 evenly spaced values
rng = np.random.default_rng(seed=42) # reproducible random numbers
print(rng.random(3).round(3))
Always create random generators with a seed in experiments; it makes results reproducible, which is essential when you compare models or prompts.
Shape, dimensions, and data types
shape is a tuple giving the size of each dimension — (2, 3) means 2 rows and 3 columns. dtype is the type shared by every element, such as int64, float32, or float64.
import numpy as np
embeddings = np.zeros((1000, 768), dtype=np.float32) # 1000 docs, 768 dims
print(embeddings.shape)
print(embeddings.ndim)
print(embeddings.dtype)
print(embeddings.size) # total number of elements
print(embeddings.nbytes / 1_000_000, "MB")
Embeddings are usually stored as float32 rather than the default float64: it halves memory with no meaningful loss of retrieval quality. At a million documents that is the difference between about 3 GB and 6 GB.
Vectorised operations
Applying an operation to a whole array at once instead of writing a Python loop over its elements. NumPy runs the loop internally in compiled code.
import numpy as np
scores = np.array([0.41, 0.88, 0.67, 0.91])
print(scores * 100) # every element times 100
print(scores + 0.05) # every element plus 0.05
print(scores >= 0.7) # element-wise comparison -> booleans
print(np.sqrt(np.array([4, 9, 16])))
a = np.array([1, 2, 3])
b = np.array([10, 20, 30])
print(a + b, a * b) # element-by-element
Compare this with the plain-Python version [s * 100 for s in scores]. For four numbers the difference is invisible; for a million embeddings it is the difference between milliseconds and seconds.
Broadcasting
NumPy's rule for combining arrays of different shapes: a smaller array is virtually “stretched” along missing or size-1 dimensions to match the larger one, without copying data.
import numpy as np
m = np.array([[1.0, 2.0, 3.0],
[4.0, 5.0, 6.0]]) # shape (2, 3)
col_means = m.mean(axis=0) # shape (3,)
print(col_means)
print(m - col_means) # (2, 3) - (3,) -> each row minus the means
row_norms = np.linalg.norm(m, axis=1, keepdims=True) # shape (2, 1)
print((m / row_norms).round(3)) # divide each row by its own length
That last line — dividing each row by its length — is exactly how embeddings are normalised before similarity search. Shapes are compatible when, comparing from the right, each pair of dimensions is equal or one of them is 1.
Aggregations and the axis argument
Aggregations reduce many values to one: sum, mean, min, max, std, argmax. On a 2-D array, axis=0 collapses the rows (one result per column) and axis=1 collapses the columns (one result per row).
import numpy as np
# rows = 3 models, columns = 4 evaluation questions
results = np.array([
[0.9, 0.7, 0.8, 0.6],
[0.6, 0.8, 0.7, 0.9],
[0.5, 0.4, 0.9, 0.8],
])
print(results.mean()) # overall average
print(results.mean(axis=1)) # average per model (row)
print(results.max(axis=0)) # best score per question (column)
print(results.mean(axis=1).argmax()) # index of the best model
argmax returns the position of the largest value, not the value itself. Models 0 and 1 tie at 0.75; argmax returns the first one.
Indexing, slicing, and boolean masks
import numpy as np
m = np.arange(12).reshape(3, 4)
print(m)
print(m[1, 2]) # row 1, column 2
print(m[0]) # first row
print(m[:, 1]) # second column, every row
print(m[:2, -2:]) # first two rows, last two columns
scores = np.array([0.41, 0.88, 0.67, 0.91])
mask = scores > 0.6
print(mask, scores[mask])
print(np.where(scores > 0.6, "keep", "drop"))
reshape(3, 4) rearranges the 12 values into 3 rows of 4 without copying them. A boolean mask selects exactly the elements where the mask is True — the NumPy equivalent of a filtered list comprehension.
Embeddings and cosine similarity
A measure of how closely two vectors point in the same direction: their dot product divided by the product of their lengths. It ranges from −1 (opposite) through 0 (unrelated) to 1 (same direction). For text embeddings, higher cosine similarity means more similar meaning.
The formula is cos(a, b) = (a · b) / (‖a‖ × ‖b‖), where a · b is the dot product (multiply matching elements, then sum) and ‖a‖ is the vector's length (its L2 norm).
import numpy as np
a = np.array([0.2, 0.5, 0.8])
b = np.array([0.1, 0.6, 0.7])
c = np.array([0.9, -0.2, 0.1])
def cosine(x, y):
return float(np.dot(x, y) / (np.linalg.norm(x) * np.linalg.norm(y)))
print(round(np.dot(a, b), 3)) # 0.02 + 0.30 + 0.56
print(round(cosine(a, b), 3)) # similar direction
print(round(cosine(a, c), 3)) # different direction
Mini semantic search with NumPy
A vector database does, at heart, exactly this: compare a query vector with every document vector and return the most similar ones. With normalised vectors, cosine similarity is just a dot product, so one matrix multiplication scores every document at once.
import numpy as np
docs = ["RAG basics", "Fine-tuning guide", "Vector databases", "Cooking pasta"]
# toy 4-dimensional "embeddings" (real ones have hundreds of dimensions)
doc_vecs = np.array([
[0.9, 0.1, 0.4, 0.0],
[0.2, 0.9, 0.1, 0.0],
[0.8, 0.0, 0.6, 0.1],
[0.0, 0.1, 0.0, 0.9],
], dtype=np.float32)
query = np.array([0.85, 0.05, 0.5, 0.0], dtype=np.float32) # "how does retrieval work?"
# 1. normalise rows to length 1
doc_unit = doc_vecs / np.linalg.norm(doc_vecs, axis=1, keepdims=True)
query_unit = query / np.linalg.norm(query)
# 2. one matrix-vector product = cosine similarity with every doc
sims = doc_unit @ query_unit
print(sims.round(3))
# 3. top-k: sort descending and take the first k positions
k = 2
top = np.argsort(-sims)[:k]
for rank, i in enumerate(top, start=1):
print(rank, docs[i], round(float(sims[i]), 3))
@ is matrix multiplication, and np.argsort(-sims) returns the indexes that would sort the similarities from highest to lowest. Swap the toy vectors for real embeddings from an API and this is a working retrieval step — fine for up to tens of thousands of documents before you need a dedicated vector index such as FAISS or a vector database.
Shapes must line up for @: (4, 4) @ (4,) works, but mixing embeddings from different models — or different dimensions — produces errors or meaningless scores. Always embed queries and documents with the same model.
Saving and loading arrays
import numpy as np
vecs = np.random.default_rng(0).random((3, 4)).astype(np.float32)
np.save("embeddings.npy", vecs) # fast binary format
loaded = np.load("embeddings.npy")
print(loaded.shape, loaded.dtype, np.array_equal(vecs, loaded))
Part 2 — pandas
A library for working with labelled, tabular data. It is built on NumPy and provides two main structures: Series and DataFrame.
A one-dimensional labelled array — a single column of values plus an index that labels each row.
A two-dimensional table of rows and named columns, where each column is a Series and can have its own type. Think of a spreadsheet or SQL table that you control with code.
In AI projects, pandas is the tool for inspecting datasets before training, analysing evaluation results, comparing prompts or models, and exporting clean data to CSV or JSONL.
Creating a DataFrame
The most common way is from a list of dicts — the same records you learned to build in earlier lessons. Each dict becomes a row and each key becomes a column.
import pandas as pd
rows = [
{"query": "what is rag", "model": "small", "hit": True, "score": 0.81, "latency_ms": 420},
{"query": "fine tuning", "model": "small", "hit": False, "score": 0.44, "latency_ms": 390},
{"query": "what is rag", "model": "large", "hit": True, "score": 0.93, "latency_ms": 910},
{"query": "fine tuning", "model": "large", "hit": True, "score": 0.77, "latency_ms": 880},
{"query": "agents", "model": "small", "hit": True, "score": 0.69, "latency_ms": None},
]
df = pd.DataFrame(rows)
print(df)
print(df.shape)
print(list(df.columns))
The numbers on the left (0–4) are the index. The missing latency became NaN (“Not a Number”), pandas' marker for missing numeric data, and the column turned into floats so it can hold it.
Inspecting data
| Method | What it shows |
|---|---|
df.head(n) / df.tail(n) | First / last n rows (default 5). |
df.shape | (rows, columns). |
df.info() | Column names, types, and non-null counts — the first thing to run on new data. |
df.describe() | Count, mean, std, min, quartiles, and max of numeric columns. |
df["col"].value_counts() | How often each value appears. |
df.isna().sum() | Number of missing values per column. |
print(df[["score", "latency_ms"]].describe().round(2))
print(df["model"].value_counts())
print(df.isna().sum()["latency_ms"])
Selecting columns and rows
print(df["score"].tolist()) # one column -> Series
print(df[["query", "score"]].head(2)) # several columns -> DataFrame
print(df.loc[2, "query"]) # by index label and column name
print(df.iloc[0, 3]) # by integer position
| Accessor | Selects by | Example |
|---|---|---|
df["col"] | Column name | df["score"] |
df.loc[rows, cols] | Labels (index values and column names) | df.loc[df["hit"], "query"] |
df.iloc[rows, cols] | Integer positions | df.iloc[:3, :2] |
Filtering rows
Filtering uses boolean masks, just like NumPy. Combine conditions with & (and), | (or), and ~ (not), and wrap each condition in parentheses.
hits = df[df["hit"]]
print(hits[["query", "model", "score"]])
strong_small = df[(df["model"] == "small") & (df["score"] > 0.6)]
print(strong_small["query"].tolist())
print(df[df["query"].isin(["agents", "fine tuning"])].shape[0])
Use & and |, not and/or, and always parenthesise each condition. df[df.model == "small" and df.score > 0.6] raises “The truth value of a Series is ambiguous”.
Creating and transforming columns
df["score_pct"] = (df["score"] * 100).round().astype(int)
df["grade"] = df["score"].apply(lambda s: "pass" if s >= 0.7 else "fail")
df["query_words"] = df["query"].str.split().str.len()
df["latency_ms"] = df["latency_ms"].fillna(df["latency_ms"].median())
print(df[["query", "score_pct", "grade", "query_words", "latency_ms"]])
- Arithmetic on columns is vectorised, just like NumPy.
.apply(func)runs a Python function on each value — flexible, but slower; prefer vectorised operations for large data.- The
.straccessor gives you vectorised string methods:.str.lower(),.str.contains(),.str.len(). fillna()replaces missing values;dropna()removes rows that contain them.
Sorting and grouping
Splits the rows into groups by the values of one or more columns, applies an aggregation (mean, count, sum…) to each group, and combines the results into a new table — the “split–apply–combine” pattern, equivalent to SQL's GROUP BY.
print(df.sort_values("score", ascending=False)[["query", "model", "score"]].head(3))
summary = df.groupby("model").agg(
runs=("query", "count"),
hit_rate=("hit", "mean"),
avg_score=("score", "mean"),
p50_latency=("latency_ms", "median"),
).round(3)
print(summary)
In two lines you now have a model comparison table: the large model hits more often and scores higher, but with roughly double the latency. This exact pattern — group evaluation runs by model or prompt version and aggregate — is how AI teams decide what to ship.
Reading and writing files
| Format | Read | Write |
|---|---|---|
| CSV | pd.read_csv("f.csv") | df.to_csv("f.csv", index=False) |
| JSONL | pd.read_json("f.jsonl", lines=True) | df.to_json("f.jsonl", orient="records", lines=True, force_ascii=False) |
| Parquet | pd.read_parquet("f.parquet") | df.to_parquet("f.parquet") (needs pyarrow) |
| Excel | pd.read_excel("f.xlsx") | df.to_excel("f.xlsx", index=False) (needs openpyxl) |
df[["query", "model", "score"]].to_json("results.jsonl", orient="records", lines=True)
print(open("results.jsonl", encoding="utf-8").read().splitlines()[0])
back = pd.read_json("results.jsonl", lines=True)
print(back.shape)
Pass index=False to to_csv, otherwise pandas writes the index as an unnamed extra column. For large datasets prefer Parquet: it is compressed, keeps column types, and loads far faster than CSV.
NumPy or pandas?
| Use NumPy when… | Use pandas when… |
|---|---|
| Data is purely numeric and same-typed (embeddings, logits, pixel arrays). | Data has named columns of mixed types (text, numbers, booleans, dates). |
| You need maximum speed for maths: dot products, norms, matrix multiplication. | You need to filter, group, join, and summarise records. |
| Feeding data into PyTorch, scikit-learn, or FAISS. | Exploring a dataset, analysing eval results, exporting CSV/JSONL. |
They work together: df["score"].to_numpy() gives you a NumPy array, and pd.DataFrame(array, columns=[...]) wraps an array in a table.
- Create a 5×8 random matrix of “document embeddings” with
np.random.default_rng(7)and a random query vector, then return the top-3 most similar rows by cosine similarity. - Build a DataFrame of 8 evaluation rows with columns
prompt_version(v1/v2),correct(bool), andtokens(int). - Group by
prompt_versionto get accuracy and mean tokens, sort by accuracy, and save the summary tosummary.csv.
- A NumPy
ndarrayis a fast, same-typed grid of numbers; check itsshapeanddtypeconstantly. - Vectorised operations and broadcasting replace Python loops and are dramatically faster.
- Cosine similarity = dot product of normalised vectors;
matrix @ queryplusargsortis a complete top-k search. - A pandas DataFrame is a table of named, typed columns built easily from a list of dicts.
- Filter with boolean masks (
&,|,~), add columns with vectorised maths, and summarise withgroupby().agg(). - Read and write CSV, JSONL, and Parquet directly with pandas.