Build your own AI research assistant
An agent that reads a folder of your own PDFs and answers questions with citations, using retrieval and tool use. The exact pattern behind real AI products today.
How the pieces connect
My tutoring experience offers you the opportunity to learn from and work with an experienced professional in data science and AI, and is fully personalised to support your goals and development. I accept students or professionals of any age and I am DBS certified.
I'm a data scientist and published AI researcher. I use these tools daily, so the examples are real and the material evolves with the field. That includes the tacit, on-the-job knowledge that bootcamps and AI assistants tend to miss.
Every session builds towards an independent project on an AI or data science topic of your choosing. You leave with something concrete: work that stands out in a job or university application.
My method is grounded in established pedagogy, including Rosenshine's Principles of Instruction. Daily practice between sessions keeps you reviewing and consolidating the concepts that matter most.
Built from my professional experience and four years at Cambridge Assessment, the curriculum is personalised and progresses from fundamentals to advanced concepts. It follows a spiral structure, revisiting earlier material at increasing depth.
Support doesn't end with the last lesson. I help with job and university applications and run mock data science interviews, informed by having sat on the other side of the table myself.
The curriculum is personalised towards your needs and goals. What follows is a taster of some of the curriculum topics which we could cover. Click a stage to open it.
By the end of this stage you'll write Python on your own and read other people's code without dread. We cover the fundamentals: variables, functions, loops and data structures, along with the habits I've picked up over the years for writing clean code, both by hand and with AI tools. There's room for SQL and R too, if they suit your goals.
-
Stack one playing card for every possible shuffle and the pile stands 2.4 × 1064 metres tall. The observable universe is roughly 1027 metres across, so the pile overshoots it by about 37 zeros. One pack of cards.
So where does that number come from? Let’s build it up properly, one small piece at a time.
Before going anywhere near 52 cards, do it by hand with 4.
4! = 4 x 3 x 2 x 1 = 24
But you can’t type “52 × 51 × … × 1” and hope Python fills in the dots. The hand calculation needs to become a pattern Python can repeat on its own.
# Same idea as the hand calculation, generalised. Start a running total
# at 1 (the "do nothing yet" value for multiplication), then multiply
# in each whole number from 1 up to n.
n = 4
running_total = 1
for i in range(1, n + 1): # i takes the values 1, 2, 3, 4
running_total = running_total * i
print(f"{n}! via loop = {running_total}") # 24, matching the hand version4! via loop = 24
This is the bit the arithmetic doesn’t tell you on its own. Take three cards, A, K, Q, and list every order they could sit in. Each ordering is called a permutation, and Python’s itertools library will generate the lot.
# itertools.permutations produces every possible ordering of a set of
# items, so we can check the link between factorials and shuffling
# rather than take it on trust.
import itertools
cards = ['A', 'K', 'Q']
orderings = list(itertools.permutations(cards))
for ordering in orderings:
print(ordering)
print(f"Total orderings: {len(orderings)}") # 6, and 3! = 3 x 2 x 1 = 6('A', 'K', 'Q')
('A', 'Q', 'K')
('K', 'A', 'Q')
('K', 'Q', 'A')
('Q', 'A', 'K')
('Q', 'K', 'A')
Total orderings: 6
Six orderings for three cards, and 3! = 6. Here’s why. The first card can be any of 3; once it’s placed, 2 remain for the second slot; then 1 for the last. Multiply the choices, 3 × 2 × 1, and you have the factorial. The same argument works for 52 cards, and the multiplication it produces is exactly the loop in step A.
The same loop as step A, now with n = 52.
Possible shuffles: 8.066e+67
Digits in that number: 68
Nobody can picture a 68-digit number, so give every shuffle its own card and measure the pile.
Height of that stack: 2.42e+64 m
A height of 2.4 × 1064 metres still means nothing on its own; there’s no intuition to borrow at that scale. One way to get a grip on a number that size is to multiply together the three largest quantities most people can actually name and see how close you land. We’ll do it in stages, so every assumption and every multiplication is out in the open.
# Each value is written in Python's scientific notation: 4.4e26 means
# 4.4 x 10^26. The part before the "e" is the digits, the part after
# is the power of ten scaling them. It's the same notation Python has
# been printing above, only written by hand this time.
# Assumption: the standard estimate for the radius of the observable universe.
edge_of_universe_m = 4.4e26 # metres
# Assumption: the age of the universe, about 13.8 billion years, in seconds.
seconds_since_big_bang = 4.4e17 # seconds
# Assumption: a commonly cited estimate (University of Hawaii, 2003) for
# every grain of sand on every beach and desert on Earth.
grains_of_sand_on_earth = 1.3e20 # a count, so no units
print(f"Edge of the universe: {edge_of_universe_m:.1e} m")
print(f"Seconds since Big Bang: {seconds_since_big_bang:.1e} s")
print(f"Grains of sand on Earth: {grains_of_sand_on_earth:.1e}")Edge of the universe: 4.4e+26 m
Seconds since Big Bang: 4.4e+17 s
Grains of sand on Earth: 1.3e+20
One line would give the same answer, but it would hide the working. Going in stages shows each intermediate value, and shows the rule for multiplying numbers in scientific notation: multiply the leading digits, add the powers of ten. So (4.4 × 1026) × (4.4 × 1017) = (4.4 × 4.4) × 1026+17.
Edge of universe x seconds since Big Bang: 1.94e+44
...x grains of sand on Earth: 2.52e+64
Rather than eyeball two similar-looking numbers, divide one by the other and let the ratio settle it.
Those three multiplied: 2.52e+64 m
Our card stack: 2.42e+64 m
Ratio of the two: 1.04
There’s no hidden formula connecting these quantities; the near-match is a fluke, though a handy one. The distance to the edge of the universe, the seconds since the Big Bang and every grain of sand on Earth, multiplied together, land within 4% of the card stack. None of them comes anywhere near on its own. It takes all three at once to keep up with a single shuffled deck.
Pandas, NumPy and matplotlib: loading data, cleaning it, exploring it and turning it into clear pictures. Underneath all of it sits one habit, and this whole stage is built to drill it in: never trust a summary statistic you haven't plotted.
The village bakery sells ten kinds of loaf. Ask Python for the average price and it reports £2.47 — which would puzzle the regulars, who mostly hand over £1.20 and get change. Nobody has fiddled the numbers. The word “average” is simply doing something it doesn’t advertise, and a few lines of Python are enough to catch it at it.
# Every loaf on the shelf, priced in pounds. Three everyday loaves
# at £1.20, the rest creeping upwards, and one showpiece sourdough.
prices = [1.20, 1.20, 1.20, 1.40, 1.60, 1.80, 2.00, 2.20, 2.60, 9.50]
mean = sum(prices) / len(prices) # add them all up, share out equally
print(f"The 'average' loaf: £{mean:.2f}")
cheaper = sum(p < mean for p in prices)
print(f"Loaves cheaper than the 'average': {cheaper} of {len(prices)}")The 'average' loaf: £2.47
Loaves cheaper than the 'average': 8 of 10
The culprit is the £9.50 chocolate-and-cherry sourdough — bought for birthdays, otherwise left to sit in the window looking splendid. The average quoted above is the mean, and the mean shares the shelf’s total out equally, so one extravagant loaf quietly inflates all the rest. “Average” is really three different ideas sharing a word, and the other two are rather harder to lead astray.
# The MEDIAN: line the loaves up by price and take the middle one.
# With ten loaves there are two middle prices, so average that pair.
ordered = sorted(prices)
median = (ordered[4] + ordered[5]) / 2
# The MODE: the price that appears on the most tags.
from collections import Counter
mode, count = Counter(prices).most_common(1)[0]
print(f"mean £{mean:.2f} <- what the headline quotes")
print(f"median £{median:.2f} <- the loaf in the middle of the shelf")
print(f"mode £{mode:.2f} <- the price on most of the tags ({count} loaves)")mean £2.47 <- what the headline quotes
median £1.70 <- the loaf in the middle of the shelf
mode £1.20 <- the price on most of the tags (3 loaves)
Three averages, more than a pound apart, all describing one small shelf. Numbers first, then always the picture — and once every loaf has its name back, the trick has nowhere to hide.
import matplotlib.pyplot as plt
# The same prices, with their names back, cheapest first
loaves = ['White tin', 'Bloomer', 'Cob', 'Wholemeal', 'Farmhouse',
'Granary', 'Seeded batch', 'Rye', 'Ciabatta',
'Choc-cherry sourdough']
fig, ax = plt.subplots(figsize=(7, 4.2))
ax.barh(loaves, prices, height=0.62, color='#AEBEB0', zorder=2)
ax.invert_yaxis() # cheapest at the top, showpiece at the bottom
# Price on the end of each bar (pale backing so the lines can't strike it through)
for i, p in enumerate(prices):
ax.text(p + 0.12, i, f'£{p:.2f}', va='center', fontsize=8.5, color='#545A63',
bbox=dict(facecolor='#FDFCFA', edgecolor='none', pad=1.2), zorder=4)
# The three "averages" as vertical lines through the shelf
for value, label, colour in [(mean, f'mean £{mean:.2f}', '#3C6E97'),
(median, f'median £{median:.2f}', '#688E76'),
(mode, f'mode £{mode:.2f}', '#976840')]:
ax.axvline(value, color=colour, linewidth=1.8, linestyle='--', label=label, zorder=3)
ax.set_xlim(0, 10.4)
ax.set_xticks(range(0, 11, 2), [f'£{v}' for v in range(0, 11, 2)])
ax.set_title('The whole shelf, priced', fontsize=12, fontweight='bold')
ax.legend(loc='center right', frameon=False, fontsize=9)
ax.xaxis.grid(True, color='#DDD8CE', linewidth=0.8)
ax.set_axisbelow(True)
ax.spines[['top', 'right', 'left']].set_visible(False)
ax.tick_params(left=False)
plt.tight_layout()
plt.show()
Nine bars stop short of £3; one carries on to £9.50. The mean line crosses the chart where no loaf actually sits — dearer than eight of the ten, dragged there by a single showpiece — while the median and mode stay down among the loaves people actually buy. Each average answers a different question. The mean splits the shelf’s total takings evenly, so ask it about revenue. The median finds the middle of the shelf, so ask it what’s typical. The mode is the price you’re most likely to pay at the till. None of them is lying; the only mistake is not asking which question got answered.
Now run the shelf yourself. Drag any price — the sourdough is the one to watch — and the mean will chase it while the median stays put. Click an empty spot to add a loaf at that price; the line underneath keeps score of how misleading the “average” currently is.
-
A bare list of numbers is hard to feel. A picture of how they are spread — a distribution — is much easier. Below is a whole day of the bakery’s sales on that same price axis: most loaves cluster cheap, a few dear ones trail off to the right. Take hold of the curve and sculpt it — pull the peak up, drag the tail out — or drop in a ready-made symmetrical, right-skew or left-skew shape, and watch the three averages slide apart, the mean carried furthest because it is the only one that feels how far the stragglers are.
Press Symmetrical and the three averages stack almost on top of each other. Choose a skew and they separate in a fixed order — the mode stays back at the peak, the mean is carried out towards the long tail, and the median sits between them. That last chip, the gap between mean and mode, is a quick gauge of skew: spot it in a chart and you know the data is lopsided before you compute a thing.
One shape turns up over and over — heights, exam marks, the little errors in a measurement: the normal distribution, better known as the bell curve. Its appeal is that two numbers pin it down completely: where it is centred, and how far its values sit, on average, from that centre. Slide the two and watch a whole population of “people” respond.
The dashed line is the centre; the arrow shows the average distance from it. Slide the centre and the whole bell glides sideways; stretch the average distance and it spreads flatter and wider, or pinches tall and narrow. Notice, too, that each fresh sample of “people” has its own centre and its own average distance that hover near the ones you set without ever landing exactly on them: real data is always a sample like this, an estimate rather than the truth.
In the last section we eyeballed the spread as the average distance from the mean. Turning that instinct into a proper number takes only a handful of moves, and the box below walks through them one press at a time — from a single value’s distance out to the figure every statistician ends up quoting. Toggle the annotations if you want the running commentary, or fold the whole box away once the idea has landed.
Here is the whole chain in code, worked on two five-a-side teams with the same mean height but very different spreads.
means: 175 cm and 175 cm
# The chain from the equations above, for team B
mean = team_b.mean()
deviations = team_b - mean # x_i - x-bar
squared = deviations ** 2 # (x_i - x-bar)^2, minus signs gone
variance = squared.mean() # s^2, the average squared deviation
sd = np.sqrt(variance) # s, back in centimetres
print(f"deviations: {deviations}")
print(f"variance s^2 = {variance:.1f} cm^2")
print(f"std dev s = {sd:.1f} cm (NumPy: {team_b.std():.1f} cm)")
print(f"team A, for comparison: s = {team_a.std():.1f} cm")deviations: [-15. -7. 0. 7. 15.]
variance s^2 = 109.6 cm^2
std dev s = 10.5 cm (NumPy: 10.5 cm)
team A, for comparison: s = 1.4 cm
Same average, completely different teams. The variance is a stepping-stone in awkward units (squared centimetres); the standard deviation is the figure people actually quote, back in plain centimetres. Team A’s players sit about 1.4 cm from their average, Team B’s about 10.5 cm — the mean alone could never tell them apart.
Now see the whole family on one bell. Drag the spread, then switch each quantity on or off to watch where it lives on the curve and how it is written.
Everything so far describes a single column of numbers. The real questions usually involve two at once: does revision time track with exam marks, height with weight, price with demand? The tool for that — the covariance — is already hiding inside the variance from the last section: one small edit turns the one into the other, and a second step turns it into the most quoted number in data science. Step through it below.
Now watch those equations at work on a live scatter. The dashed crosshair marks the two means, x̄ and ȳ — every point’s contribution to the covariance sum is measured from those two lines.
Push the correlation positive and the cloud leans into an upward slope; push it negative and the slope flips; near zero it settles into a shapeless blob. Notice the chips underneath doing exactly what the card above promised: the covariance moves with the slope, and dividing by the two spreads turns it into the tidy r between −1 and +1.
import numpy as np
# Revision hours vs exam mark for eight students
hours = np.array([1, 2, 2, 3, 4, 4, 5, 6])
marks = np.array([45, 52, 49, 60, 66, 70, 74, 80])
cov = np.cov(hours, marks, ddof=0)[0, 1] # covariance between the two
corr = np.corrcoef(hours, marks)[0, 1] # covariance / (sd_hours * sd_marks)
print(f"covariance {cov:.1f} correlation {corr:+.2f}")covariance 18.4 correlation +0.99
To finish, a warning about trusting those numbers too far. The Datasaurus Dozen is thirteen datasets built on purpose to share almost identical means, standard deviations and correlation — to two decimal places. On the summary table alone, you would file them all as the same data.
import pandas as pd
# Thirteen small datasets, each just an x and a y column
df = pd.read_csv("data/datasaurus.csv")
stats = df.groupby("dataset").agg(
x_mean=("x", "mean"), y_mean=("y", "mean"),
x_sd=("x", "std"), y_sd=("y", "std"),
)
for name, g in df.groupby("dataset"):
stats.loc[name, "corr"] = g["x"].corr(g["y"])
print(stats.round(2)) x_mean y_mean x_sd y_sd corr
dataset
away 54.27 47.83 16.77 26.94 -0.06
bullseye 54.27 47.83 16.77 26.94 -0.07
circle 54.27 47.84 16.76 26.93 -0.07
dino 54.26 47.83 16.77 26.94 -0.06
dots 54.26 47.84 16.77 26.93 -0.06
h_lines 54.26 47.83 16.77 26.94 -0.06
high_lines 54.27 47.84 16.77 26.94 -0.07
slant_down 54.27 47.84 16.77 26.94 -0.07
slant_up 54.27 47.83 16.77 26.94 -0.07
star 54.27 47.84 16.77 26.93 -0.06
v_lines 54.27 47.84 16.77 26.94 -0.07
wide_lines 54.27 47.83 16.77 26.94 -0.07
x_shape 54.26 47.84 16.77 26.93 -0.07
Thirteen datasets and, to two decimal places, every one has the same means, the same standard deviations and the same correlation. Identical on the numbers.
So plot them. Every one of the thirteen is an option below. Pick any and the 142 points glide from one shape to the next — and the whole time they are mid-flight, the statistics underneath sit still. That is the trick made visible: the numbers genuinely cannot see the picture.
One of them is a dinosaur. A dataset with perfectly ordinary summary statistics turns out to be a drawing of a dinosaur, and its twelve siblings — a star, a circle, an X, sets of lines — are just as different. The numbers could not tell any of them apart, yet a single plot each makes the differences obvious. Compute the statistics, then plot the data; and if the two ever disagree, believe the plot.
Scikit-learn, model training, evaluation, and feature engineering. We cover the models you will actually use: linear and logistic regression, decision trees, random forests, gradient boosting. Equally important: how to know when your model is working, when it is not, and why.
This content is locked
These interactive examples are part of our 1-2-1 sessions.
Enquire about tutoring →PyTorch, neural networks, transformers, and how to put LLMs to work. We cover the concepts properly: what a transformer actually does, how fine-tuning works, and how to use LLM APIs to build real things. Because some of these methods are so new, this is the stage most courses skip or oversimplify.
This content is locked
These interactive examples are part of our 1-2-1 sessions.
Enquire about tutoring →An independent piece of work on a topic of your choosing, taken from scoping the question all the way to a polished, documented project you own. Here are a few directions students and professionals have run with. Use the arrows to browse.
An agent that reads a folder of your own PDFs and answers questions with citations, using retrieval and tool use. The exact pattern behind real AI products today.
How the pieces connect
Collect your own images (plant species, football kits, anything) and train a classifier, then ship a live demo people can drag a photo straight into.
How the pieces connect
Use a decade of streaming data to predict whether a track will chart, then dissect which audio features actually separate a hit from a flop.
How the pieces connect
Stream thousands of articles or reviews, score their sentiment, and chart how opinion shifts around real-world events as they unfold.
How the pieces connect
Energy use, a crypto price, your team's results: build a forecaster that is honest about its own uncertainty instead of pretending it knows the future.
How the pieces connect
Reproduce the headline result of a recent AI paper from scratch, then stress-test where it holds up and where it quietly breaks. Catnip for admissions tutors.
How the pieces connect