Tutoring

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.

Who this is for

Professionals
  • Learn what AI is so you can talk confidently to colleagues
  • Upgrade your data skills to use in your current role
  • Move into AI engineering or data science from another field
Students
  • Build life skills in AI and data in a relaxed and encouraging environment
  • Use these sessions to supplement what you are learning in STEM
  • Apply for competitive school or university places

My Unique Selling Point

Taught by a practitioner

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.

Build your own portfolio

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.

Designed with learning in mind

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.

A curriculum built around you

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 for job and university applications

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

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.

01 Python Foundations

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.

Try it Drag the deck size and see how far the numbers get away from you.
1 cards
Ways to shuffle-
Digits-
Card-stack height-

-

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.

AWhat a factorial is

Before going anywhere near 52 cards, do it by hand with 4.

Show the code
# A factorial means "multiply this number by every whole number below it,
# down to 1". It's written n! and read "n factorial". Start with 4!,
# one multiplication at a time, so there's nothing hidden.

four_factorial = 4 * 3 * 2 * 1
print(f"4! = 4 x 3 x 2 x 1 = {four_factorial}")   # 24
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.

Show the code
# 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 version
4! via loop = 24
BWhy factorials count shuffles

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.

Show the code
# 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.

CThe full deck, as a pile

The same loop as step A, now with n = 52.

Show the code
shuffles = 1
for n in range(1, 53):           # n takes the values 1, 2, 3, ... , 52
    shuffles = shuffles * n

print(f"Possible shuffles:      {shuffles:.3e}")          # 8.066e+67
print(f"Digits in that number:  {len(str(shuffles))}")    # 68
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.

Show the code
card_thickness_m = 0.3e-3                        # a card is about 0.3 mm thick
stack_height_m   = shuffles * card_thickness_m
print(f"Height of that stack:   {stack_height_m:.2e} m")  # 2.42e+64 m
Height of that stack:   2.42e+64 m
DThree enormous numbers vs the deck

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.

Show the code
# 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.

Show the code
first_two = edge_of_universe_m * seconds_since_big_bang
print(f"Edge of universe x seconds since Big Bang: {first_two:.2e}")  # 1.94e+44

product = first_two * grains_of_sand_on_earth
print(f"...x grains of sand on Earth:              {product:.2e}")    # 2.52e+64
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.

Show the code
print(f"Those three multiplied: {product:.2e} m")        # 2.52e+64 m
print(f"Our card stack:         {stack_height_m:.2e} m") # 2.42e+64 m

ratio = product / stack_height_m
print(f"Ratio of the two:       {ratio:.2f}")   # 1.04, within 4% of each other
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.

Frank Morley
FrankThis is where I like to start people. A few readable lines of code, and out drops a number bigger than nearly anything in physics. And look at what those lines actually contained: variables, a loop, a running total, formatted printing. That's a decent chunk of everyday Python already. If you could follow the working above, you're further along than you think.
02 Working with Data

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.

Frank Morley
FrankMost courses sprint through this to reach the "exciting" machine learning. I slow down here. In my work at Cambridge and the BBC, the people you could trust with a model were always the ones who understood their data first. Everything below is interactive: drag the points, sculpt the curves, pull the sliders, and watch the numbers move.
AThree kinds of “average”

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.

Show the code
# 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.

Show the code
# 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.

Show the code
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.

Try it Drag a price — the sourdough's the one to watch. Click empty space to add a loaf.

-

BThe shape behind the numbers

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.

Try it Sculpt the curve with your cursor, or pick a shape. The brush-curviness slider sets how rounded your strokes come out. The three averages slide as the shape changes.

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.

CThe normal distribution

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.

Try it One slider slides the bell along; the other stretches or squashes it. Each dot is one simulated person.

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.

DSpread, written down exactly

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.

The mathematics, step by stepclick to fold away

Here is the whole chain in code, worked on two five-a-side teams with the same mean height but very different spreads.

Show the code
import numpy as np

# Two teams, identical mean height (175 cm), very different spreads
team_a = np.array([173, 174, 175, 176, 177])   # tightly grouped
team_b = np.array([160, 168, 175, 182, 190])   # all over the place

print(f"means: {team_a.mean():.0f} cm and {team_b.mean():.0f} cm")
means: 175 cm and 175 cm
Show the code
# 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.

Try it Drag the spread, then toggle each quantity. Notice the variance grows as the square of the standard deviation.
ETwo variables at once: covariance and correlation

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.

From variance to correlation, step by stepclick to fold away

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.

Try it Drag the correlation and watch the cloud tilt and tighten around a line. Or press Guess it: a mystery cloud appears, you dial in your estimate, then reveal the answer.

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.

Show the code
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
FSame statistics, different pictures

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.

Show the code
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.

Try it Pick a dataset and watch the points fly to their new shape — while the statistics underneath refuse to move.

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.

Frank Morley
FrankThat habit — numbers first, then always the picture — is what this whole stage is built around. A mean can be dragged about by one outlier, a standard deviation tells you spread but nothing about shape, and thirteen "identical" datasets can hide a dinosaur. Summary statistics are where an investigation starts, never where it ends.
03 Machine Learning

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 →
04 Deep Learning and Large Language Models

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 →
05 Your Portfolio Project

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.

Frank Morley
FrankA certificate says you completed a course. A project shows what you can actually do, to an employer or an admissions tutor. This is what I care most about.
Get in touch