Tutorial

CNN

1

D

With corr2d_multi_in_out, our model is able to find a bunch of clues in an image.
Now we give it the ability to make decisions using these clues.
The decision making is through the linear function.

@op
def linear[o, i](
    x: Tensor[float][[i]],
    A: Tensor[float][[o, i]],
    b: Tensor[float][[o]]
) -> Tensor[float][[o]]:
    return (x * A).sum(1) + b
2

W

So linear converts a Tensor[float][[i]] to a Tensor[float][[o]]?
Seems to be helpful: we need to convert a Tensor[float][[16, 5, 5]] to a Tensor[float][[10]] in the last chapter.

3

D

We do it using three layers of linear. Here's the complete predict of LeNet.

class LeNet(Model):
    def predict(
        x: Tensor[float][[1, 28, 28]],
        params: Tuple[
            Tensor[float][[6, 1, 5, 5]],
            Tensor[float][[6]],
            Tensor[float][[16, 6, 5, 5]],
            Tensor[float][[16]],
            Tensor[float][[120, 16 * 5 * 5]],
            Tensor[float][[120]],
            Tensor[float][[84, 120]],
            Tensor[float][[84]],
            Tensor[float][[10, 84]],
            Tensor[float][[10]]
        ]
    ) -> Tensor[float][[10]]:
        (p1, b1, p2, b2, lA1, lb1, lA2, lb2, lA3, lb3) = params
        layer1 = larger(corr2d_multi_in_out(x, p1, b1, 1, 1, 2, 2), 0)
        layer1 = pool2d_avg(layer1, 2, 2, 2, 2, 0, 0)
        layer2 = larger(corr2d_multi_in_out(layer1, p2, b2, 1, 1, 0, 0), 0)
        layer2 = pool2d_avg(layer2, 2, 2, 2, 2, 0, 0)
        flat = layer2.reshape([16 * 5 * 5])
        layer3 = larger(linear(flat, lA1, lb1), 0)
        layer4 = larger(linear(layer3, lA2, lb2), 0)
        layer5 = linear(layer4, lA3, lb3)
        return layer5

After layer4, we use reshape to flatten the rank-3 tensor to rank-1.

4

W

A model needs predict, loss, and update. One down, two to go!

5

D

loss starts with the output of predict. What are the desired properties of this Tensor[float][[10]]?

6

W

How about a list of probabilities?
Like: 70% this is a T-shirt, 20% for a dress, 2% for a sneaker ...

7

D

Good. We need a list of probabilities that add up to 1, and each should be non-negative.
Here's our first function.

@op
def softmax[n](x: Tensor[float][[n]]) -> Tensor[float][[n]]:
    x_exp = x.exp()
    return x_exp / x_exp.sum(0)

We raise Euler's number to the power of each score, ensuring positivity,
and then divide these positive numbers by their sum.

8

W

Why Euler's number? How about 2 or 3?

9

D

Any positive base works, but Euler's number is usually more convenient.
The gradient of x.exp() is itself. So learning can be made more efficient.
Let's run an example.

10

W

Like this?

print(softmax(Tensor([1.0, -2.0, 3.0])))

It prints Tensor([0.1184, 0.0058, 0.8756]).
The highest score becomes the highest probability.

11

D

With softmax, we may define our loss function: cross_entropy.
There are n possible items and m inputs. For each input, ys_pred stores predicted scores for all items. ys contains the indices of the actual items, such as 0 for T-shirt, 1 for dress, and 2 for sneaker.

@op
def cross_entropy[m, n](
    ys_pred: Tensor[float][[m, n]],
    ys: Tensor[int][[m]]
) -> float:
    target_probs = [
        probs[idx]
        for (probs, idx) in zip(softmax(ys_pred), ys)
    ]
    return -target_probs.log().avg(0)
12

W

Then softmax turns predicted scores into probabilities.
target_probs is a Tensor[float][[m]], where each element is the probability of the actual item.
We collapse the Tensor[float][[m]] into a float using mean.
But why do we need log?

13

D

It amplifies the loss when our model is confidently wrong.
If a picture is a dress but our probability is 0.00001, then the logged loss is 9.21, much larger than a linear penalty.

14

W

An example would be helpful.

pred = Tensor([[2.0, 5.0, -6.0], [1.0, 3.0, 5.1]])
indices = Tensor([0, 2])
print(cross_entropy(pred, indices))

It prints 1.589--the loss for this prediction.

15

D

Our LeNet is complete!

learning_rate = 0.1

class LeNet(Model):
    def predict(
        x: Tensor[float][[1, 28, 28]],
        params: Tuple[
            Tensor[float][[6, 1, 5, 5]],
            Tensor[float][[6]],
            Tensor[float][[16, 6, 5, 5]],
            Tensor[float][[16]],
            Tensor[float][[120, 16 * 5 * 5]],
            Tensor[float][[120]],
            Tensor[float][[84, 120]],
            Tensor[float][[84]],
            Tensor[float][[10, 84]],
            Tensor[float][[10]]
        ]
    ) -> Tensor[float][[10]]:
        (p1, b1, p2, b2, lA1, lb1, lA2, lb2, lA3, lb3) = params
        layer1 = larger(corr2d_multi_in_out(x, p1, b1, 1, 1, 2, 2), 0)
        layer1 = pool2d_avg(layer1, 2, 2, 2, 2, 0, 0)
        layer2 = larger(corr2d_multi_in_out(layer1, p2, b2, 1, 1, 0, 0), 0)
        layer2 = pool2d_avg(layer2, 2, 2, 2, 2, 0, 0)
        flat = layer2.reshape([16 * 5 * 5])
        layer3 = larger(linear(flat, lA1, lb1), 0)
        layer4 = larger(linear(layer3, lA2, lb2), 0)
        layer5 = linear(layer4, lA3, lb3)
        return layer5

    def loss[m](ys_pred: Tensor[float][[m, 10]], ys: Tensor[int][[m]]) -> float:
        return cross_entropy(ys_pred, ys)

    def update(p: float, g: float) -> float:
        return p - learning_rate * g

learning_rate is a hyperparameter. We may tune it for better learning results.

16

W

LeNet has a lot of parameters. Is there an easier way to track them?

17

D

Our upcoming model has even more parameters. So yes, we need a better way to organize the parameters.
Let's start with revamping linear.

class LinearParams[o, i]:
    A: Tensor[float][[o, i]]
    b: Tensor[float][[o]]

@op
def linear[i, o](
    x: Tensor[float][[i]],
    p: LinearParams[float, o, i]
) -> Tensor[float][[o]]:
    return (x * p.A).sum(1) + p.b

Now it's your turn. Revamp corr2d_multi_in_out.

18

W

I see. We are using class to pack and hide parameters.

class Corr2dParams[o, i, m, n]:
    w: Tensor[float][[o, i, m, n]]
    b: Tensor[float][[o]]

@op
def corr2d_multi_in_out[o, i, h, w, m, n](
    s: Tensor[float][[i, h, w]],
    p: Corr2dParams[o, i, m, n],
    stride0: int,
    stride1: int,
    padding0: int,
    padding1: int
) -> Tensor[float][
    [
        o,
        (h + 2 * padding0 - m + stride0) / stride0,
        (w + 2 * padding1 - n + stride1) / stride1
    ]
]:
    return corr2d_multi_in(s, p.w, p.b, stride0, stride1, padding0, padding1)

Here's the new corr2d_multi_in_out.

19

D

Next we create a class for LeNet parameters. How many corr2d layers and linear layers are there?

20

W

Our LeNet has two layers of corr2d and three layers of linear.

21

D

So, here are the parameters for LeNet.

class LeNetParams:
    corr1: Corr2dParams[6, 1, 5, 5]
    corr2: Corr2dParams[16, 6, 5, 5]
    linear1: LinearParams[float, 120, 400]
    linear2: LinearParams[float, 84, 120]
    linear3: LinearParams[float, 10, 84]

We pack two parameters for two corr2d layers, and three parameters for three linear layers. We also instantiate the os and is with concrete numbers.

22

W

Now we can rework LeNet!

23

D

Yes, here's our final LeNet, with parameters properly organized.

class LeNet(Model):
    def predict(
        x: Tensor[float][[1, 28, 28]],
        p: LeNetParams
    ) -> Tensor[float][[10]]:
        layer1 = larger(corr2d_multi_in_out(x, p.corr1, 1, 1, 2, 2), 0)
        layer1 = pool2d(layer1, 2, 2, 2, 2, 0, 0)
        layer2 = larger(corr2d_multi_in_out(layer1, p.corr2, 1, 1, 0, 0), 0)
        layer2 = pool2d(layer2, 2, 2, 2, 2, 0, 0)
        flat = layer2.reshape([16 * 5 * 5])
        layer3 = larger(linear(flat, p.linear1), 0)
        layer4 = larger(linear(layer3, p.linear2), 0)
        layer5 = linear(layer4, p.linear3)
        return layer5

    def loss[n](ys_pred: Tensor[float][[n, 10]], ys: Tensor[int][[n]]) -> float:
        return cross_entropy(ys_pred, ys)

    def update(p: float, g: float) -> float:
        return p - lr * g

Now it's time to take a break. In the next chapter, we will build a transformer.

24

W

We are here! We are waiting!

The code of this chapter is available at PyPie's GitHub repository.