PyTorch, TensorFlow & Keras

Machine Learning · Lesson 27 of 32 7 min read Updated

scikit-learn is the right tool for classic machine learning, but it has no convolutional, recurrent or transformer layers and cannot use a GPU. For deep learning, people use dedicated frameworks. The three names you will meet everywhere are PyTorch, TensorFlow and Keras.

In this lesson you will see what these frameworks do for you, write a tiny network by hand in NumPy to appreciate it, and read the same network in PyTorch and in Keras. The framework code is not runnable in the browser (the libraries are far too large), so it is shown as plain examples to run on your own computer or on a free cloud notebook.

What a deep learning framework gives you

All three frameworks provide the same core pieces:

  • Tensors: multi-dimensional arrays like NumPy arrays, which can live on a GPU.
  • Automatic differentiation (autograd): you write the forward pass, and the framework computes every gradient for backpropagation.
  • Layers and models: dense, convolution, recurrent, attention, normalisation and many more, ready to combine.
  • Optimisers and losses: SGD, Adam, cross-entropy and friends.
  • Data loading, saving and deployment tools, plus access to thousands of pretrained models.
FrameworkMade byStyleLatest release (30 Oct 2026)
PyTorchStarted at Meta; now run by the PyTorch FoundationPythonic, you write the training loop2.15.0 (28 October 2026)
TensorFlowGoogleLarge ecosystem for production and mobile2.21.0 (March 2026)
KerasGoogle and communityHigh-level API on top of JAX, TensorFlow or PyTorch3.15.1 (July 2026)
JAXGoogleNumPy-like functions with fast compilation; popular in research-

PyTorch is the most common choice in research and in many companies today, and most open pretrained models are published for it. TensorFlow remains widely used in production systems and on mobile devices. Keras offers the simplest code and, since Keras 3, can run on any of three backends.

What autograd saves you from

To appreciate autograd, here is a two-layer network trained on the two-moons data with every gradient written by hand. The backward pass applies the chain rule layer by layer, exactly as described in Neural Networks with MLPClassifier.

PythonRun it yourself
import numpy as np
from sklearn.datasets import make_moons

X, y = make_moons(n_samples=400, noise=0.2, random_state=42)
y = y.reshape(-1, 1)
rng = np.random.default_rng(42)
W1, b1 = rng.normal(0, 1, (2, 16)), np.zeros(16)    # input -> 16 hidden neurons
W2, b2 = rng.normal(0, 1, (16, 1)), np.zeros(1)     # hidden -> 1 output
learning_rate = 0.5

def sigmoid(z):
    return 1 / (1 + np.exp(-z))

for epoch in range(1, 2001):
    h = np.tanh(X @ W1 + b1)                     # forward pass
    p = sigmoid(h @ W2 + b2)
    loss = -np.mean(y * np.log(p + 1e-9) + (1 - y) * np.log(1 - p + 1e-9))

    dz2 = (p - y) / len(X)                       # backward pass, by hand
    dW2, db2 = h.T @ dz2, dz2.sum(axis=0)
    dh = dz2 @ W2.T * (1 - h ** 2)               # derivative of tanh
    dW1, db1 = X.T @ dh, dh.sum(axis=0)

    W1 -= learning_rate * dW1                    # gradient descent step
    b1 -= learning_rate * db1
    W2 -= learning_rate * dW2
    b2 -= learning_rate * db2
    if epoch in (1, 10, 100, 500, 2000):
        print(f"epoch {epoch:>4}: loss {loss:.3f}, accuracy {np.mean((p > 0.5) == y):.3f}")
Output
epoch    1: loss 0.702, accuracy 0.642
epoch   10: loss 0.358, accuracy 0.833
epoch  100: loss 0.256, accuracy 0.880
epoch  500: loss 0.105, accuracy 0.965
epoch 2000: loss 0.071, accuracy 0.968

It works, but the backward pass took as many lines as the forward pass, and every new layer type would need its own hand-derived gradients. A network with 50 layers of different kinds would be impossible to maintain this way. Frameworks remove the whole "backward pass" section: you call one method and every gradient is computed for you.

The same network in PyTorch

In PyTorch you define the model, a loss and an optimiser, then write the training loop yourself. loss.backward() is autograd: it replaces all the hand-written gradient lines above. Moving work to a GPU is one .to(device) call.

Python
import torch
from torch import nn
from sklearn.datasets import make_moons

X, y = make_moons(n_samples=400, noise=0.2, random_state=42)
X = torch.tensor(X, dtype=torch.float32)
y = torch.tensor(y, dtype=torch.float32).reshape(-1, 1)

device = "cuda" if torch.cuda.is_available() else "cpu"
model = nn.Sequential(nn.Linear(2, 16), nn.Tanh(), nn.Linear(16, 1)).to(device)
loss_fn = nn.BCEWithLogitsLoss()        # sigmoid and log loss in one stable step
optimizer = torch.optim.Adam(model.parameters(), lr=0.01)
X, y = X.to(device), y.to(device)

for epoch in range(500):
    optimizer.zero_grad()               # clear the previous gradients
    loss = loss_fn(model(X), y)         # forward pass
    loss.backward()                     # autograd: compute every gradient
    optimizer.step()                    # update every weight

with torch.no_grad():                   # no gradients needed for evaluation
    accuracy = ((model(X) > 0).float() == y).float().mean().item()
print(f"loss {loss.item():.3f}, accuracy {accuracy:.3f}")

For speed, PyTorch 2 added torch.compile(model), which compiles the model into faster code, often with no other changes. Real projects also use a DataLoader to feed the data in small batches.

The same network in Keras

Keras hides the loop: you describe the layers, compile the model with an optimiser and loss, and call fit. Keras 3 runs on JAX, TensorFlow or PyTorch; you choose with the KERAS_BACKEND environment variable before importing Keras.

Python
import os
os.environ["KERAS_BACKEND"] = "torch"     # or "jax" or "tensorflow"

import keras
from sklearn.datasets import make_moons

X, y = make_moons(n_samples=400, noise=0.2, random_state=42)
model = keras.Sequential([
    keras.Input(shape=(2,)),
    keras.layers.Dense(16, activation="tanh"),
    keras.layers.Dense(1, activation="sigmoid"),
])
model.compile(optimizer=keras.optimizers.Adam(learning_rate=0.01),
              loss="binary_crossentropy", metrics=["accuracy"])
model.fit(X, y, epochs=100, batch_size=32, validation_split=0.2, verbose=0)
loss, accuracy = model.evaluate(X, y, verbose=0)
print(f"loss {loss:.3f}, accuracy {accuracy:.3f}")

Notice how much the three styles share: layers of 2 → 16 → 1 neurons, a tanh activation, Adam, and binary cross-entropy (log loss). Once you understand the ideas, switching frameworks is mostly a matter of syntax. For a problem this small, scikit-learn does the same job in two lines:

PythonRun it yourself
from sklearn.datasets import make_moons
from sklearn.neural_network import MLPClassifier

X, y = make_moons(n_samples=400, noise=0.2, random_state=42)
mlp = MLPClassifier(hidden_layer_sizes=(16,), activation="tanh", solver="lbfgs",
                    max_iter=2000, random_state=42).fit(X, y)
print(f"Training accuracy: {mlp.score(X, y):.3f}")
Output
Training accuracy: 0.990

Installing and GPUs

Install inside a virtual environment. The exact PyTorch command depends on your system and GPU, and the "Get Started" page on pytorch.org builds it for you; TensorFlow and Keras are installed from PyPI.

Terminal
python -m pip install torch                      # CPU build; see pytorch.org for GPU builds
python -m pip install keras                      # Keras 3, plus a backend such as torch or jax
python -m pip install "tensorflow[and-cuda]"     # TensorFlow with NVIDIA GPU support on Linux or WSL2

A few practical facts:

  • Deep learning on a GPU is usually many times faster than on a CPU. NVIDIA GPUs use CUDA; PyTorch also supports AMD GPUs (ROCm) and Apple silicon ("mps").
  • TensorFlow 2.10 was the last version with GPU support on native Windows; on Windows, use TensorFlow inside WSL2 for GPU training.
  • No GPU at home? Free cloud notebooks such as Google Colab and Kaggle Notebooks offer limited free GPU time, which is plenty for learning.

Which one should you learn?

If you want to...Start with
Learn deep learning ideas with the least codeKeras
Follow research papers, use most open pretrained models, write custom trainingPyTorch
Maintain or deploy existing TensorFlow systems, or target mobile with LiteRT (formerly TensorFlow Lite)TensorFlow
Classify tables of datascikit-learn or gradient boosting, not deep learning

The ideas carry over between all of them, so pick one, build a few projects, and learn the others when a job requires it.

Common mistakes

  • Reaching for deep learning first. For tabular data, start with scikit-learn and gradient boosting.
  • Forgetting optimizer.zero_grad() in PyTorch. Gradients add up across steps unless you clear them, which silently breaks training.
  • Setting KERAS_BACKEND after importing Keras. It must be set before the first import keras.
  • Installing a GPU build that does not match your driver. Use the official install selector and check with torch.cuda.is_available().

Key points

  • Deep learning frameworks provide GPU tensors, automatic differentiation, ready-made layers and optimisers.
  • Autograd computes all gradients from the forward pass, replacing hand-written backpropagation.
  • PyTorch (2.15) gives you an explicit training loop; Keras (3.15) gives compile and fit and runs on JAX, TensorFlow or PyTorch; TensorFlow (2.21) has a large production ecosystem.
  • Since TensorFlow 2.16, tf.keras is Keras 3; GPU TensorFlow on Windows needs WSL2.
  • Use free cloud GPUs to learn, and keep scikit-learn for classic machine learning.

Exercise

In the hand-written NumPy network, change the learning rate to 0.05 and then to 100, keeping 2,000 epochs. Print the loss and accuracy at epochs 10, 100, 1,000 and 2,000. What happens with a learning rate that is too small, and with one that is too large?

Show solution
Python
import numpy as np
from sklearn.datasets import make_moons

def train(learning_rate):
    X, y = make_moons(n_samples=400, noise=0.2, random_state=42)
    y = y.reshape(-1, 1)
    rng = np.random.default_rng(42)
    W1, b1 = rng.normal(0, 1, (2, 16)), np.zeros(16)
    W2, b2 = rng.normal(0, 1, (16, 1)), np.zeros(1)
    for epoch in range(1, 2001):
        h = np.tanh(X @ W1 + b1)
        p = 1 / (1 + np.exp(-(h @ W2 + b2)))
        dz2 = (p - y) / len(X)
        dh = dz2 @ W2.T * (1 - h ** 2)
        W2 -= learning_rate * (h.T @ dz2)
        b2 -= learning_rate * dz2.sum(axis=0)
        W1 -= learning_rate * (X.T @ dh)
        b1 -= learning_rate * dh.sum(axis=0)
        if epoch in (10, 100, 1000, 2000):
            loss = -np.mean(y * np.log(p + 1e-9) + (1 - y) * np.log(1 - p + 1e-9))
            print(f"  lr {learning_rate}, epoch {epoch:>4}: loss {loss:.3f}, accuracy {np.mean((p > 0.5) == y):.3f}")

for lr in [0.05, 100.0]:
    train(lr)
Output
lr 0.05, epoch   10: loss 0.561, accuracy 0.698
lr 0.05, epoch  100: loss 0.355, accuracy 0.838
lr 0.05, epoch 1000: loss 0.255, accuracy 0.880
lr 0.05, epoch 2000: loss 0.189, accuracy 0.927
lr 100.0, epoch   10: loss 8.809, accuracy 0.570
lr 100.0, epoch  100: loss 7.564, accuracy 0.635
lr 100.0, epoch 1000: loss 2.066, accuracy 0.900
lr 100.0, epoch 2000: loss 1.905, accuracy 0.875

With 0.05 the loss falls slowly but steadily; after 2,000 epochs it is still well behind the run with 0.5. With 100 the steps are so large that the weights overshoot: the loss jumps up and down, often far above its starting value, and the accuracy never settles. A good learning rate lies between these extremes, and adaptive optimisers such as Adam make the choice much less delicate.

Quick Quiz

1. What does automatic differentiation (autograd) do?

2. In PyTorch, which call computes the gradients?

3. Which backends can Keras 3 run on?

4. What does tf.keras refer to in TensorFlow 2.16 and later?

Learn Machine Learning with a mentor

Free lessons get you started. Our Data Science & Machine Learning training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call