3.2. Linear Regression Implementation from Scratch
Open the notebook in Colab
Open the notebook in Colab
Open the notebook in Colab
Open the notebook in SageMaker Studio Lab

Agora que você entende as principais ideias por trás da regressão linear, podemos começar a trabalhar por meio de uma implementação prática no código. Nesta seção, vamos implementar todo o método do zero, incluindo o pipeline de dados, o modelo, a função de perda e o otimizador de descida gradiente estocástico do minibatch. Embora as estruturas modernas de deep learning possam automatizar quase todo esse trabalho, implementar coisas do zero é a única maneira para ter certeza de que você realmente sabe o que está fazendo. Além disso, quando chega a hora de personalizar modelos, definindo nossas próprias camadas ou funções de perda, entender como as coisas funcionam nos bastidores será útil. Nesta seção, contaremos apenas com tensores e diferenciação automática. Posteriormente, apresentaremos uma implementação mais concisa, aproveitando sinos e assobios de frameworks de deep learning.

%matplotlib inline
import random
from mxnet import autograd, np, npx
from d2l import mxnet as d2l

npx.set_np()
%matplotlib inline
import random
import torch
from d2l import torch as d2l
%matplotlib inline
import random
import tensorflow as tf
from d2l import tensorflow as d2l

3.2.1. Gerando o Dataset

Para manter as coisas simples, iremos construir um conjunto de dados artificial de acordo com um modelo linear com ruído aditivo. Nossa tarefa será recuperar os parâmetros deste modelo usando o conjunto finito de exemplos contidos em nosso conjunto de dados. Manteremos os dados em baixa dimensão para que possamos visualizá-los facilmente. No seguinte snippet de código, geramos um conjunto de dados contendo 1000 exemplos, cada um consistindo em 2 features amostrado a partir de uma distribuição normal padrão. Assim, nosso conjunto de dados sintético será uma matriz \(\mathbf{X}\in\mathbb{R}^{1000\times 2}\).

Os verdadeiros parâmetros que geram nosso conjunto de dados serão \(\mathbf{w} = [2, -3,4]^\top\) e \(b = 4,2\), e nossos rótulos sintéticos serão atribuídos de acordo ao seguinte modelo linear com o termo de ruído \(\epsilon\):

(3.2.1)\[\mathbf{y}= \mathbf{X} \mathbf{w} + b + \mathbf\epsilon.\]

Você pode pensar em \(\epsilon\) como um potencial de captura erros de medição nos recursos e rótulos. Vamos assumir que as premissas padrão são válidas e, portanto, que \(\epsilon\) obedece a uma distribuição normal com média 0. Para tornar nosso problema mais fácil, definiremos seu desvio padrão em 0,01. O código a seguir gera nosso conjunto de dados sintético.

def synthetic_data(w, b, num_examples):  #@save
    """Generate y = Xw + b + noise."""
    X = np.random.normal(0, 1, (num_examples, len(w)))
    y = np.dot(X, w) + b
    y += np.random.normal(0, 0.01, y.shape)
    return X, y.reshape((-1, 1))

true_w = np.array([2, -3.4])
true_b = 4.2
features, labels = synthetic_data(true_w, true_b, 1000)
def synthetic_data(w, b, num_examples):  #@save
    """Generate y = Xw + b + noise."""
    X = torch.normal(0, 1, (num_examples, len(w)))
    y = torch.matmul(X, w) + b
    y += torch.normal(0, 0.01, y.shape)
    return X, y.reshape((-1, 1))

true_w = torch.tensor([2, -3.4])
true_b = 4.2
features, labels = synthetic_data(true_w, true_b, 1000)
def synthetic_data(w, b, num_examples):  #@save
    """Generate y = Xw + b + noise."""
    X = tf.zeros((num_examples, w.shape[0]))
    X += tf.random.normal(shape=X.shape)
    y = tf.matmul(X, tf.reshape(w, (-1, 1))) + b
    y += tf.random.normal(shape=y.shape, stddev=0.01)
    y = tf.reshape(y, (-1, 1))
    return X, y

true_w = tf.constant([2, -3.4])
true_b = 4.2
features, labels = synthetic_data(true_w, true_b, 1000)

Observe que cada linha em features consiste em um exemplo de dados bidimensionais e que cada linha em labels consiste em um valor de rótulo unidimensional (um escalar).

print('features:', features[0],'\nlabel:', labels[0])
features: [2.2122064 1.1630787]
label: [4.662078]
print('features:', features[0],'\nlabel:', labels[0])
features: tensor([-0.9563,  1.2447])
label: tensor([-1.9621])
print('features:', features[0],'\nlabel:', labels[0])
features: tf.Tensor([1.1301318  0.14684609], shape=(2,), dtype=float32)
label: tf.Tensor([5.960015], shape=(1,), dtype=float32)

Ao gerar um gráfico de dispersão usando o segundo recurso features [:, 1] e labels, podemos observar claramente a correlação linear entre os dois.

d2l.set_figsize()
# The semicolon is for displaying the plot only
d2l.plt.scatter(features[:, (1)].asnumpy(), labels.asnumpy(), 1);
../_images/output_linear-regression-scratch_58de05_39_0.svg
d2l.set_figsize()
# The semicolon is for displaying the plot only
d2l.plt.scatter(features[:, (1)].detach().numpy(), labels.detach().numpy(), 1);
../_images/output_linear-regression-scratch_58de05_42_0.svg
d2l.set_figsize()
# The semicolon is for displaying the plot only
d2l.plt.scatter(features[:, (1)].numpy(), labels.numpy(), 1);
../_images/output_linear-regression-scratch_58de05_45_0.svg

3.2.2. Lendo o Dataset

Lembre-se de que os modelos de treinamento consistem em fazer várias passagens sobre o dataset, pegando um minibatch de exemplos por vez, e usando-los para atualizar nosso modelo. Uma vez que este processo é tão fundamental para treinar algoritmos de amachine learning, vale a pena definir uma função de utilidade para embaralhar o conjunto de dados e acessá-lo em minibatches.

No código a seguir, nós definimos a função data_iter para demonstrar uma possível implementação dessa funcionalidade. A função leva um tamanho de amostra, uma matriz de features, e um vetor de labels, produzindo minibatches do tamanho batch_size. Cada minibatch consiste em uma tupla de features e labels.

def data_iter(batch_size, features, labels):
    num_examples = len(features)
    indices = list(range(num_examples))
    # The examples are read at random, in no particular order
    random.shuffle(indices)
    for i in range(0, num_examples, batch_size):
        batch_indices = np.array(
            indices[i: min(i + batch_size, num_examples)])
        yield features[batch_indices], labels[batch_indices]
def data_iter(batch_size, features, labels):
    num_examples = len(features)
    indices = list(range(num_examples))
    # The examples are read at random, in no particular order
    random.shuffle(indices)
    for i in range(0, num_examples, batch_size):
        batch_indices = torch.tensor(
            indices[i: min(i + batch_size, num_examples)])
        yield features[batch_indices], labels[batch_indices]
def data_iter(batch_size, features, labels):
    num_examples = len(features)
    indices = list(range(num_examples))
    # The examples are read at random, in no particular order
    random.shuffle(indices)
    for i in range(0, num_examples, batch_size):
        j = tf.constant(indices[i: min(i + batch_size, num_examples)])
        yield tf.gather(features, j), tf.gather(labels, j)

Em geral, queremos usar minibatches de tamanhos razoáveis para aproveitar as vantagens do hardware da GPU, que se destaca em operações de paralelização. Porque cada exemplo pode ser alimentado por meio de nossos modelos em paralelo e o gradiente da função de perda para cada exemplo também pode ser tomado em paralelo, GPUs nos permitem processar centenas de exemplos em pouco mais tempo do que pode demorar para processar apenas um único exemplo.

Para construir alguma intuição, vamos ler e imprimir o primeiro pequeno lote de exemplos de dados. A forma dos recursos em cada minibatch nos diz o tamanho do minibatch e o número de recursos de entrada. Da mesma forma, nosso minibatch de rótulos terá uma forma dada por batch_size.

batch_size = 10

for X, y in data_iter(batch_size, features, labels):
    print(X, '\n', y)
    break
[[ 1.0702589   0.22332872]
 [ 0.576972    0.8087885 ]
 [ 0.10064353  0.64281934]
 [ 1.2020247  -2.1224012 ]
 [-1.0674467  -0.34258512]
 [ 0.20714448  1.0840217 ]
 [ 0.90336937 -0.38090217]
 [ 0.8332984   1.8107505 ]
 [ 1.8917114  -1.1688148 ]
 [ 0.59860843 -3.0636313 ]]
 [[ 5.5869737 ]
 [ 2.5922568 ]
 [ 2.215121  ]
 [13.813773  ]
 [ 3.2241936 ]
 [ 0.91169757]
 [ 7.301087  ]
 [-0.2817729 ]
 [11.958657  ]
 [15.81957   ]]
batch_size = 10

for X, y in data_iter(batch_size, features, labels):
    print(X, '\n', y)
    break
tensor([[-0.0649,  0.4390],
        [-0.2518, -0.4019],
        [-0.1489,  0.2960],
        [ 1.6701, -0.8914],
        [ 0.6946,  0.2719],
        [-1.4623,  0.5890],
        [ 0.1270,  0.7019],
        [-1.2410,  0.1549],
        [-0.3620, -0.1373],
        [-0.2483, -1.6446]])
 tensor([[ 2.5737],
        [ 5.0419],
        [ 2.8981],
        [10.5841],
        [ 4.6693],
        [-0.7264],
        [ 2.0609],
        [ 1.1768],
        [ 3.9539],
        [ 9.2774]])
batch_size = 10

for X, y in data_iter(batch_size, features, labels):
    print(X, '\n', y)
    break
tf.Tensor(
[[-0.5730527   0.7819388 ]
 [-1.4290377   0.501779  ]
 [-1.0716456  -0.3164782 ]
 [-0.7258405   0.5719521 ]
 [ 1.7161779  -0.6173083 ]
 [-1.5024987   1.2860105 ]
 [ 0.5211034  -1.16397   ]
 [ 0.5002108  -0.70382583]
 [ 0.2605837   1.0829461 ]
 [ 0.4482001  -0.56022495]], shape=(10, 2), dtype=float32)
 tf.Tensor(
[[ 0.38191256]
 [-0.36548588]
 [ 3.1524463 ]
 [ 0.80933195]
 [ 9.73811   ]
 [-3.1788385 ]
 [ 9.195568  ]
 [ 7.5800247 ]
 [ 1.0323184 ]
 [ 7.0033584 ]], shape=(10, 1), dtype=float32)

Conforme executamos a iteração, obtemos minibatches distintos sucessivamente até que todo o conjunto de dados se esgote (tente isto). Embora a iteração implementada acima seja boa para fins didáticos, é ineficiente de maneiras que podem nos colocar em apuros em problemas reais. Por exemplo, requer que carreguemos todos os dados na memória e que realizamos muitos acessos aleatórios à memória. Os iteradores integrados implementados em uma estrutura de deep learning são consideravelmente mais eficientes e podem lidar com dados armazenados em arquivos e dados alimentados por meio de fluxos de dados.

3.2.3. Initializing Model Parameters

Antes de começarmos a otimizar os parâmetros do nosso modelo por gradiente descendente estocástico de minibatch, precisamos ter alguns parâmetros em primeiro lugar. No código a seguir, inicializamos os pesos por amostragem números aleatórios de uma distribuição normal com média 0 e um desvio padrão de 0,01, e definindo a tendência para 0.

w = np.random.normal(0, 0.01, (2, 1))
b = np.zeros(1)
w.attach_grad()
b.attach_grad()
w = torch.normal(0, 0.01, size=(2,1), requires_grad=True)
b = torch.zeros(1, requires_grad=True)
w = tf.Variable(tf.random.normal(shape=(2, 1), mean=0, stddev=0.01),
                trainable=True)
b = tf.Variable(tf.zeros(1), trainable=True)

Depois de inicializar nossos parâmetros, nossa próxima tarefa é atualizá-los até eles se ajustam aos nossos dados suficientemente bem. Cada atualização requer a obtenção do gradiente da nossa função de perda no que diz respeito aos parâmetros. Dado este gradiente, podemos atualizar cada parâmetro na direção que pode reduzir a perda.

Uma vez que ninguém quer calcular gradientes explicitamente (isso é entediante e sujeito a erros), usamos diferenciação automática, conforme apresentado em Section 2.5, para calcular o gradiente.

3.2.4. Definindo o Modelo

Em seguida, devemos definir nosso modelo, relacionando suas entradas e parâmetros com suas saídas. Lembre-se de que, para calcular a saída do modelo linear, simplesmente pegamos o produto escalar vetor-matriz dos recursos de entrada \(\mathbf{X}\) e os pesos do modelo \(\mathbf{w}\), e adicione o offset \(b\) a cada exemplo. Observe que abaixo de \(\mathbf{Xw}\) está um vetor e \(b\) é um escalar. Lembre-se do mecanismo de transmissão conforme descrito em Section 2.1.3. Quando adicionamos um vetor e um escalar, o escalar é adicionado a cada componente do vetor.

def linreg(X, w, b):  #@save
    """The linear regression model."""
    return np.dot(X, w) + b
def linreg(X, w, b):  #@save
    """The linear regression model."""
    return torch.matmul(X, w) + b
def linreg(X, w, b):  #@save
    """The linear regression model."""
    return tf.matmul(X, w) + b

3.2.5. Definindo a Função de Perda

Uma vez que atualizar nosso modelo requer tomar o gradiente de nossa função de perda, devemos definir a função de perda primeiro. Aqui vamos usar a função de perda quadrada conforme descrito em Section 3.1. Na implementação, precisamos transformar o valor verdadeiro y na forma do valor previsto y_hat. O resultado retornado pela seguinte função também terá a mesma forma de y_hat.

def squared_loss(y_hat, y):  #@save
    """Squared loss."""
    return (y_hat - y.reshape(y_hat.shape)) ** 2 / 2
def squared_loss(y_hat, y):  #@save
    """Squared loss."""
    return (y_hat - y.reshape(y_hat.shape)) ** 2 / 2
def squared_loss(y_hat, y):  #@save
    """Squared loss."""
    return (y_hat - tf.reshape(y, y_hat.shape)) ** 2 / 2

3.2.6. Definindo o Algoritmo de Otimização

Como discutimos em Section 3.1, a regressão linear tem uma solução de forma fechada. No entanto, este não é um livro sobre regressão linear: é um livro sobre deep learning. Uma vez que nenhum dos outros modelos que este livro apresenta pode ser resolvido analiticamente, aproveitaremos esta oportunidade para apresentar seu primeiro exemplo de trabalho de gradiente descendente estocástico de minibatch.

Em cada etapa, usando um minibatch retirado aleatoriamente de nosso conjunto de dados, vamos estimar o gradiente da perda em relação aos nossos parâmetros. A seguir, vamos atualizar nossos parâmetros na direção que pode reduzir a perda. O código a seguir aplica a atualização da descida gradiente estocástica do minibatch, dado um conjunto de parâmetros, uma taxa de aprendizagem e um tamanho de batch. O tamanho da etapa de atualização é determinado pela taxa de aprendizagem lr. Como nossa perda é calculada como a soma do minibatch de exemplos, normalizamos o tamanho do nosso passo pelo tamanho do batch (batch_size), de modo que a magnitude de um tamanho de passo típico não depende muito de nossa escolha do tamanho do lote.

def sgd(params, lr, batch_size):  #@save
    """Minibatch stochastic gradient descent."""
    for param in params:
        param[:] = param - lr * param.grad / batch_size
def sgd(params, lr, batch_size):  #@save
    """Minibatch stochastic gradient descent."""
    with torch.no_grad():
        for param in params:
            param -= lr * param.grad / batch_size
            param.grad.zero_()
def sgd(params, grads, lr, batch_size):  #@save
    """Minibatch stochastic gradient descent."""
    for param, grad in zip(params, grads):
        param.assign_sub(lr*grad/batch_size)

3.2.7. Treinamento

Agora que temos todas as peças no lugar, estamos prontos para implementar o loop de treinamento principal. É crucial que você entenda este código porque você verá loops de treinamento quase idênticos repetidamente ao longo de sua carreira de deep learning.

Em cada iteração, pegaremos um minibatch de exemplos de treinamento, e os passamos por nosso modelo para obter um conjunto de previsões. Depois de calcular a perda, iniciamos a passagem para trás pela rede, armazenando os gradientes em relação a cada parâmetro. Finalmente, chamaremos o algoritmo de otimização de sgd para atualizar os parâmetros do modelo.

Em resumo, vamos executar o seguinte loop:

  • Inicializar parâmetros \((\mathbf{w}, b)\)

  • Repetir até terminar

    • Computar gradiente \(\mathbf{g} \leftarrow \partial_{(\mathbf{w},b)} \frac{1}{|\mathcal{B}|} \sum_{i \in \mathcal{B}} l(\mathbf{x}^{(i)}, y^{(i)}, \mathbf{w}, b)\)

    • Atualizar parâmetros \((\mathbf{w}, b) \leftarrow (\mathbf{w}, b) - \eta \mathbf{g}\)

Em cada época, iremos iterar por todo o conjunto de dados (usando a função data_iter) uma vez passando por todos os exemplos no conjunto de dados de treinamento (assumindo que o número de exemplos seja divisível pelo tamanho do lote). O número de épocas num_epochs e a taxa de aprendizagemlr são hiperparâmetros, que definimos aqui como 3 e 0,03, respectivamente. Infelizmente, definir hiperparâmetros é complicado e requer alguns ajustes por tentativa e erro. Excluímos esses detalhes por enquanto, mas os revisamos mais tarde em Section 11.

lr = 0.03
num_epochs = 3
net = linreg
loss = squared_loss

for epoch in range(num_epochs):
    for X, y in data_iter(batch_size, features, labels):
        with autograd.record():
            l = loss(net(X, w, b), y)  # Minibatch loss in `X` and `y`
        # Because `l` has a shape (`batch_size`, 1) and is not a scalar
        # variable, the elements in `l` are added together to obtain a new
        # variable, on which gradients with respect to [`w`, `b`] are computed
        l.backward()
        sgd([w, b], lr, batch_size)  # Update parameters using their gradient
    train_l = loss(net(features, w, b), labels)
    print(f'epoch {epoch + 1}, loss {float(train_l.mean()):f}')
[03:59:56] src/base.cc:49: GPU context requested, but no GPUs found.
epoch 1, loss 0.025171
epoch 2, loss 0.000088
epoch 3, loss 0.000051
lr = 0.03
num_epochs = 3
net = linreg
loss = squared_loss

for epoch in range(num_epochs):
    for X, y in data_iter(batch_size, features, labels):
        l = loss(net(X, w, b), y)  # Minibatch loss in `X` and `y`
        # Compute gradient on `l` with respect to [`w`, `b`]
        l.sum().backward()
        sgd([w, b], lr, batch_size)  # Update parameters using their gradient
    with torch.no_grad():
        train_l = loss(net(features, w, b), labels)
        print(f'epoch {epoch + 1}, loss {float(train_l.mean()):f}')
epoch 1, loss 0.044391
epoch 2, loss 0.000181
epoch 3, loss 0.000046
lr = 0.03
num_epochs = 3
net = linreg
loss = squared_loss

for epoch in range(num_epochs):
    for X, y in data_iter(batch_size, features, labels):
        with tf.GradientTape() as g:
            l = loss(net(X, w, b), y)  # Minibatch loss in `X` and `y`
        # Compute gradient on l with respect to [`w`, `b`]
        dw, db = g.gradient(l, [w, b])
        # Update parameters using their gradient
        sgd([w, b], [dw, db], lr, batch_size)
    train_l = loss(net(features, w, b), labels)
    print(f'epoch {epoch + 1}, loss {float(tf.reduce_mean(train_l)):f}')
epoch 1, loss 0.034802
epoch 2, loss 0.000124
epoch 3, loss 0.000052

Neste caso, porque nós mesmos sintetizamos o conjunto de dados, sabemos exatamente quais são os verdadeiros parâmetros. Assim, podemos avaliar nosso sucesso no treinamento comparando os parâmetros verdadeiros com aqueles que aprendemos através de nosso ciclo de treinamento. Na verdade, eles acabam sendo muito próximos um do outro.

print(f'error in estimating w: {true_w - w.reshape(true_w.shape)}')
print(f'error in estimating b: {true_b - b}')
error in estimating w: [ 0.00044966 -0.00043631]
error in estimating b: [0.00071049]
print(f'error in estimating w: {true_w - w.reshape(true_w.shape)}')
print(f'error in estimating b: {true_b - b}')
error in estimating w: tensor([ 0.0006, -0.0003], grad_fn=<SubBackward0>)
error in estimating b: tensor([0.0008], grad_fn=<RsubBackward1>)
print(f'error in estimating w: {true_w - tf.reshape(w, true_w.shape)}')
print(f'error in estimating b: {true_b - b}')
error in estimating w: [ 0.00061536 -0.00040913]
error in estimating b: [-0.00094461]

Observe que não devemos tomar isso como garantido que somos capazes de recuperar os parâmetros perfeitamente. No entanto, no machine learning, normalmente estamos menos preocupados com a recuperação de verdadeiros parâmetros subjacentes, e mais preocupados com parâmetros que levam a previsões altamente precisas. Felizmente, mesmo em problemas de otimização difíceis, o gradiente descendente estocástico pode muitas vezes encontrar soluções notavelmente boas, devido em parte ao fato de que, para redes profundas, existem muitas configurações dos parâmetros que levam a uma previsão altamente precisa.

3.2.8. Resumo

  • Vimos como uma rede profunda pode ser implementada e otimizada do zero, usando apenas tensores e diferenciação automática, sem a necessidade de definir camadas ou otimizadores sofisticados.

  • Esta seção apenas arranha a superfície do que é possível. Nas seções a seguir, descreveremos modelos adicionais com base nos conceitos que acabamos de apresentar e aprenderemos como implementá-los de forma mais concisa.

3.2.9. Exercícios

  1. O que aconteceria se inicializássemos os pesos para zero. O algoritmo ainda funcionaria?

  2. Suponha que você seja Georg Simon Ohm tentando inventar um modelo entre tensão e corrente. Você poderia usar a diferenciação automática para aprender os parâmetros do seu modelo?

  3. Você pode usar a Lei de Planck para determinar a temperatura de um objeto usando densidade de energia espectral?

  4. Quais são os problemas que você pode encontrar se quiser calcular as derivadas secundárias? Como você os consertaria?

  5. Por que a função reshape é necessária na funçãosquared_loss?

  6. Experimente usar diferentes taxas de aprendizagem para descobrir a rapidez com que o valor da função de perda diminui.

  7. Se o número de exemplos não pode ser dividido pelo tamanho do lote, o que acontece com o comportamento da função data_iter?