From what a neuron actually computes to how an LLM predicts its next word - every concept explained in plain language, with a small, dependency-free example and its real, verified output.
AI is the broad goal of building systems that perform tasks normally requiring human intelligence — recognizing images, understanding language, making decisions. The earliest AI was purely rule-based: a person hand-writes the logic. Machine learning changed that by having the system derive its own rules from data instead.
Narrow AI does one task well (spam filtering, chess); general AI would match human flexibility across any task.
Rule-based systems are still 'AI' in the classic sense — they just don't learn.
Subfields include machine learning, computer vision, natural language processing, and robotics.
what-is-ai.py
# A simple rule-based "AI": hardcoded logic, not learned from data
returnany(word in email_text.lower() for word in spam_words)
print(is_spam("You are a WINNER! Click here now"))
print(is_spam("Let's meet for lunch tomorrow"))
output
True
False
02
AI vs. Machine Learning vs. Deep Learning
These three nest inside each other: AI is the overall goal, machine learning is one approach to it — learning patterns from data instead of hand-coding rules — and deep learning is a subset of ML that uses multi-layer neural networks, which is what makes it so effective on raw, unstructured data like images and text.
Not all AI is ML — a rule-based expert system is AI but isn't learning anything.
Not all ML is deep learning — linear regression and decision trees are ML without neural networks.
The 'learning' in machine learning literally means adjusting numbers (weights) based on data.
ai-ml-dl.py
# A tiny 'machine learning' example: learn y = w*x + b from data
# instead of hardcoding it, using simple gradient descent.
data = [(1, 3), (2, 5), (3, 7), (4, 9)] # true relationship: y = 2x + 1
w, b = 0.0, 0.0
learning_rate = 0.01
for epoch inrange(1000):
for x, y in data:
pred = w * x + b
error = pred - y
w -= learning_rate * error * x
b -= learning_rate * error
print(round(w, 2), round(b, 2))
output
2.0 1.0
03
Types of Machine Learning
Supervised learning trains on labeled examples (input paired with the correct answer) to predict labels for new data. Unsupervised learning finds structure in unlabeled data, like grouping similar items together. Reinforcement learning trains an agent to make decisions by rewarding good outcomes and penalizing bad ones over many trials.
Supervised: classification (categories) and regression (numbers) are the two main tasks.
Unsupervised: clustering and dimensionality reduction are the most common uses.
Reinforcement learning is how game-playing agents and robotics controllers are typically trained.
types-of-ml.py
# Supervised learning: predict a label from labeled examples
A neural network is layers of neurons, each combining its inputs with learned weights, adding a bias, and passing the result through an activation function. A single neuron on its own is simple — it's stacking many of them across layers, and learning their weights from data, that lets a network approximate complex functions.
Every connection between neurons has a weight, learned during training.
The bias shifts the neuron's output independent of its inputs.
Forward pass = computing an output; backward pass (backpropagation) = updating weights based on error.
neural-networks.py
import math
defsigmoid(x):
return1 / (1 + math.exp(-x))
defneuron(inputs, weights, bias):
total = sum(i * w for i, w inzip(inputs, weights)) + bias
returnsigmoid(total)
inputs = [1.0, 0.5, -1.5]
weights = [0.4, 0.3, 0.2]
bias = 0.1
print(round(neuron(inputs, weights, bias), 4))
output
0.5866
05
Activation Functions
An activation function decides how much signal a neuron passes forward, and — critically — introduces non-linearity. Without one, stacking layers would mathematically collapse into a single linear function, no matter how many layers you added, so activations are what let networks learn curved, complex relationships at all.
Sigmoid squashes any input into (0, 1) — useful for probabilities, but prone to vanishing gradients.
ReLU (max(0, x)) is the default for hidden layers in most modern networks — simple and fast.
Tanh squashes into (-1, 1), centered at zero, which sometimes helps training stability.
A loss function measures how wrong a model's predictions are — smaller is better. Gradient descent is the algorithm that nudges every weight in the direction that reduces that loss, a tiny step at a time, repeated over many iterations until the model converges on good parameters.
Mean squared error (MSE) is the standard loss for regression — it penalizes big errors more than small ones.
The learning rate controls how big each nudge is — too high overshoots, too low trains painfully slowly.
'Training a model' is really just 'running gradient descent to minimize a loss function'.
A model only proves it generalizes if it's evaluated on data it never saw during training. The training set teaches the model, the validation set tunes decisions like hyperparameters along the way, and the test set gives one final, honest measurement of performance — touched only once, at the very end.
A common split is roughly 60/20/20 or 80/10/10, depending on how much data you have.
Never let test data leak into training — that inflates your accuracy and hides real problems.
Cross-validation rotates which slice is 'validation' to get a more reliable estimate on small datasets.
train-val-test.py
data = list(range(1, 11)) # pretend this is 10 labeled examples
train = data[:6] # 60% for training
val = data[6:8] # 20% for validation
test = data[8:] # 20% for final testing
print("train:", train)
print("val: ", val)
print("test: ", test)
output
train: [1, 2, 3, 4, 5, 6]
val: [7, 8]
test: [9, 10]
08
Overfitting & Regularization
Overfitting is when a model memorizes the noise and quirks of its training data instead of the underlying pattern — it looks brilliant on training data and falls apart on anything new. Regularization techniques (like L2 penalties or dropout) deliberately constrain the model so it's forced to learn general patterns instead.
A big gap between training accuracy and test accuracy is the classic overfitting symptom.
More training data, simpler models, and regularization are the three main fixes.
Dropout randomly disables neurons during training, forcing the network not to rely on any single one.
A CNN slides a small learned filter (a kernel) across an image, computing a weighted sum at each position to detect local patterns like edges, textures, or shapes. Stacking convolutional layers lets early layers detect simple features and later layers combine them into increasingly abstract ones — edges become shapes become objects.
The same kernel is reused across the whole image — this weight-sharing is what makes CNNs efficient.
Pooling layers shrink the image between convolutions, keeping the strongest signals.
CNNs dominate computer vision because they exploit an image's spatial structure directly.
return [sum(row[i + j] * k[j] for j inrange(len(k)))
for i inrange(len(row) - len(k) + 1)]
for row in image:
print(convolve_row(row, kernel))
output
[0, 10, 10]
[0, 10, 10]
[0, 10, 10]
10
RNNs & Sequence Models
A recurrent neural network processes a sequence one element at a time while carrying a 'hidden state' forward — a running memory of everything it's seen so far. That memory is what lets RNNs handle inputs where order matters, like text or time-series data, unlike a plain feedforward network that has no concept of 'before' and 'after'.
The same weights are reused at every step of the sequence.
Plain RNNs struggle to remember far-back information — LSTMs and GRUs were designed to fix that.
Transformers (next topic) have mostly replaced RNNs for large-scale sequence modeling.
rnns-sequences.py
defsimple_rnn_step(x, hidden, w_x=0.5, w_h=0.5):
return w_x * x + w_h * hidden
sequence = [1, 2, 3, 4]
hidden = 0.0
for x in sequence:
hidden = simple_rnn_step(x, hidden)
print(round(hidden, 3))
output
0.5
1.25
2.125
3.062
11
Transformers & Attention
Attention lets a model weigh how relevant every other element in a sequence is to the one it's currently processing, instead of relying on a single carried-forward memory like an RNN. The transformer architecture is built entirely around this idea, which is what lets it process a whole sequence in parallel and track long-range relationships effectively.
Each token compares itself against every other token via a query/key dot product.
Softmax turns those raw comparison scores into weights that sum to 1.
Because attention has no built-in sense of order, transformers add positional information separately.
transformers-attention.py
import math
defdot(a, b):
returnsum(x * y for x, y inzip(a, b))
defsoftmax(scores):
exps = [math.exp(s) for s in scores]
total = sum(exps)
return [e / total for e in exps]
query = [1, 0]
keys = [[1, 0], [0, 1], [1, 1]]
scores = [dot(query, k) for k in keys]
weights = softmax(scores)
print([round(w, 3) for w in weights])
output
[0.422, 0.155, 0.422]
12
Large Language Models (LLMs)
An LLM is a transformer trained on massive amounts of text to predict the next token in a sequence, over and over. That single, simple objective — repeated across billions of examples — is enough to make the model implicitly learn grammar, facts, and reasoning patterns, all as a side effect of getting better at prediction.
'Large' refers to both the amount of training data and the number of parameters (weights).
A base model just predicts text; instruction-tuning and RLHF shape it into a helpful assistant.
An LLM's 'knowledge' is frozen at its training cutoff — it doesn't know about anything after that.
llms.py
from collections import defaultdict
# A tiny toy 'language model': predict the next word from bigram counts
bigrams = defaultdict(list)
text = "the cat sat on the mat the cat sat".split()
for i inrange(len(text) - 1):
bigrams[text[i]].append(text[i + 1])
defpredict_next(word):
options = bigrams[word]
returnmax(set(options), key=options.count) if options elseNone
print(predict_next("the"))
print(predict_next("cat"))
output
cat
sat
13
Tokenization
Before any text reaches a model, it has to be broken into tokens — the discrete units the model actually operates on. Real LLMs use subword tokenization (like BPE), which splits text into frequent chunks smaller than a full word, so the model can handle rare words and typos without needing a token for every possible word in the language.
Splitting on whitespace is the simplest possible tokenizer, but it treats 'fast!' and 'fast' as different tokens.
Subword tokenizers keep common words whole and break rare ones into familiar pieces.
A model's context window is measured in tokens, not characters or words.
tokenization.py
import re
text = "CodeNFacts helps you learn, fast!"
# Naive whitespace tokenizer
print(text.split())
# A simple regex tokenizer that splits off punctuation
Prompt engineering is structuring the input to a model so it reliably produces what you want, instead of leaving the model to guess your intent. Concrete instructions, relevant context, and a clear question all reduce ambiguity — a well-structured prompt is closer to a spec than a casual request.
Giving the model a role ('You are a helpful tutor') sets tone and framing before the actual task.
Separating task, context, and question makes a prompt easier for the model to parse reliably.
'Answer step by step' is a simple, effective way to encourage more careful reasoning.
prompt-engineering.py
defbuild_prompt(task, context, question):
return f"""You are a helpful tutor.
Task: {task}
Context: {context}
Question: {question}
Answer step by step."""
prompt = build_prompt(
task="Explain a concept simply",
context="The student is new to programming",
question="What is a variable?"
)
print(prompt)
output
You are a helpful tutor.
Task: Explain a concept simply
Context: The student is new to programming
Question: What is a variable?
Answer step by step.
15
Embeddings & Vector Search
An embedding is a list of numbers representing the meaning of a piece of text or data, positioned so similar meanings sit close together in that space. Cosine similarity measures how close two embeddings point in the same direction, which is exactly what powers semantic search — finding relevant results even when the wording doesn't match.
Cosine similarity ranges from -1 (opposite) to 1 (identical direction), ignoring vector length.
Words or sentences with similar meaning end up with embeddings that point in similar directions.
Vector databases index embeddings so nearest-neighbor search stays fast even at massive scale.
embeddings-vector-search.py
import math
defcosine_similarity(a, b):
dot = sum(x * y for x, y inzip(a, b))
norm_a = math.sqrt(sum(x ** 2for x in a))
norm_b = math.sqrt(sum(y ** 2for y in b))
return dot / (norm_a * norm_b)
# pretend these are embeddings for "cat", "dog", "car"
cat = [0.9, 0.1, 0.0]
dog = [0.8, 0.2, 0.0]
car = [0.0, 0.1, 0.9]
print(round(cosine_similarity(cat, dog), 3)) # similar meaning
RAG grounds a model's answer in real data by retrieving relevant documents first, then feeding them into the prompt alongside the question. This lets a model answer accurately about information it was never trained on — private documents, recent events — without retraining it, at the cost of only being as good as what gets retrieved.
The retrieval step usually uses vector search over document embeddings, not keyword matching alone.
The retrieved text gets inserted into the prompt as context before the model generates an answer.
Poor retrieval (missing or irrelevant documents) is the most common cause of a bad RAG answer.
rag.py
documents = {
"doc1": "Python lists are ordered and mutable collections",
"doc2": "Tuples are immutable and often used for fixed data",
"doc3": "Dictionaries store key-value pairs for fast lookup",
doc3 -> Dictionaries store key-value pairs for fast lookup
17
Fine-tuning & Transfer Learning
Transfer learning reuses a model that already learned general features from a huge dataset, then adapts it to a narrower task with far less data than training from scratch would need. Fine-tuning often freezes most of the pretrained weights and only trains a small new part on top — much cheaper than retraining everything.
Freezing early layers keeps general features intact while the new layer specializes.
Fine-tuning typically needs far less data and compute than pretraining from scratch.
LoRA and similar techniques go further, training only a small set of additional parameters.
fine-tuning-transfer.py
# Pretrained weights (imagine these took days of GPU time to learn)
pretrained_hidden_weights = [0.7, -0.3, 0.5]
# Fine-tuning: freeze the hidden weights, only train a new final layer
In reinforcement learning, an agent takes actions in an environment and receives rewards, gradually learning which actions lead to better outcomes through trial and error rather than labeled examples. Q-learning is one of the simplest versions: keep a running estimate of each action's value, and nudge that estimate toward whatever reward you actually observed.
There's no 'correct answer' given upfront — the agent only learns from the reward signal.
Exploration (trying new actions) has to be balanced against exploitation (using what already works).
This is the approach behind game-playing agents and many robotics control systems.
reinforcement-learning.py
import random
actions = ["left", "right"]
rewards = {"left": 1, "right": 5} # unknown to the agent ahead of time
A model trained on biased data will reproduce and often amplify that bias in its predictions — it isn't being 'fair' or 'neutral' by default, it's reflecting whatever patterns were in its training data. Checking for disparities in outcomes across groups is a basic first step toward catching this before a model ships.
Bias can enter through skewed training data, flawed labels, or the choice of what to optimize for.
Fairness has multiple competing mathematical definitions — improving one can worsen another.
Auditing outcomes by subgroup is a common first check, not a complete fairness guarantee on its own.
ai-ethics-bias.py
# A toy example: checking if a hiring model's approval rate differs by group
decisions = [
{"group": "A", "approved": True},
{"group": "A", "approved": True},
{"group": "A", "approved": False},
{"group": "B", "approved": True},
{"group": "B", "approved": False},
{"group": "B", "approved": False},
]
defapproval_rate(decisions, group):
group_decisions = [d for d in decisions if d["group"] == group]
approved = sum(1for d in group_decisions if d["approved"])
return approved / len(group_decisions)
rate_a = approval_rate(decisions, "A")
rate_b = approval_rate(decisions, "B")
print(f"Group A approval rate: {rate_a:.2f}")
print(f"Group B approval rate: {rate_b:.2f}")
print(f"Disparity: {abs(rate_a - rate_b):.2f}")
output
Group A approval rate: 0.67
Group B approval rate: 0.33
Disparity: 0.33
20
AI in Production (MLOps)
Shipping a model is a different job from training one — it needs to serve predictions reliably and be watched for drift, which is when real-world input data quietly stops resembling what the model was trained on. Left undetected, drift degrades accuracy silently, since the model keeps making confident predictions on data it's no longer well-suited for.
Monitoring input distributions, not just accuracy, catches problems before users notice bad predictions.
A/B testing and shadow deployments let you validate a new model against real traffic safely.
Most production ML failures trace back to the surrounding pipeline, not the model itself.
ai-in-production.py
import statistics
training_mean_age = 34.2# the average feature value the model was trained on
incoming_batch = [45, 50, 48, 52, 47] # a recent batch of live requests