Vimos anteriormente que podemos abstrair redes neurais como funções, e portanto esperam certas entradas e produzem saídas, mas o que são essas entradas e saídas exatamente? Tanto as entradas como saídas são tensores.

Nesta seção iremos discutir o que são tensores e como manipulá-los com Numpy.

O que são tensores?

Tensores são vetores de diferentes dimensões. Você já conhece tensores, os mais comuns tem nomes próprios:

Um tensor 0-dimensional é denominado escalar:

escalar_a = 1.0
escalar_b = -12
escalar_c = 1e-8

Um tensor uni-dimensional é um vetor (array):

vetor_a = [1.0]
vetor_b = [1.0, 2.0, 3.0]
vetor_c = [-0.33, 0.421, -789.12, 11.0, 53421093.1231, -123.1]

Um tensor bi-dimensional é uma matriz (Em Python uma lista de listas):

matriz_a = [[1.0]] # 1 x 1
matriz_b = [[1.0], [2.0], [3.0]] # 3 x 1
matriz_a = [[1.0] * 10, [2.0] * 10, [3.0] * 10] # 3 x 10

Um tensor é qualquer vetor com N-dimensões (por exemplo, um cubo é um tensor de 3 dimensões). Ainda pensando dessa forma, poderíamos imaginar um tensor 4-dimensional como um array de cubos; um tensor 5-dimensional seria uma matriz de cubos; um tensor 6-dimensional seria um cubo de cubos, e assim por diante...

tensor_a = 1.0
tensor_b = [1.0, 2.0, 3.0]
tensor_c = [[1.0]]
tensor_d = [[[1.0], [2.0], [3.0]], [[1.0], [2.0], [3.0]], [[1.0], [2.0], [3.0]]] # tensor com dimensoes: (3, 3, 1)

Visualização matemática

De maneira matemática, você pode pensar em tensores da seguinte forma:

Visualização canina

Já os matemáticos que gostam de cachorros, preferem pensar em tensores da seguinte forma:

Visão Pythonica: Numpy!

Numpy é uma biblioteca em Python para manipulação eficiente de tensores. Numpy facilita nossa vida de várias formas... desde simplificando obter as dimensões de um tensor até a implementação de varias funções para manipulação de tensores de forma eficiente.

De acordo com as próprias palavras da numpy.org (tradução livre):

Numpy é um pacote fundamental pra computação científica com Python.

Entre outras coisas, o Numpy possui:- um podereso ferramental para manipulação de arrays multi-dimensionais- funções de broadcasting sofisticadas (veremos isso já já)

  • ferramentas para integração de código Fortran e C/C++ (por baixo dos panos pra gente não ter que se preocupar)
  • utilidades para álgebra linear, tranformação de Fourier e números aleatórios

Para usar o numpy, tudo que precisamos é importá-lo:

!pip3 install numpy
Collecting numpy
  Using cached https://files.pythonhosted.org/packages/45/b2/6c7545bb7a38754d63048c7696804a0d947328125d81bf12beaa692c3ae3/numpy-1.19.5-cp36-cp36m-manylinux1_x86_64.whl
Installing collected packages: numpy
Successfully installed numpy-1.19.5
# pq numpy é muito grande pra ficar digitando toda hora
import numpy as np

Manipulando tensores

Até então Numpy parece ser um wrapper com umas funções pra facilitar iteragir com tensores... mas... o que mais nós podemos fazer com numpy?

Bem eu disse que numpy possui "um podereso ferramental para manipulação de arrais multi-dimensionais".

O que danado isso significa?

MÁGICA!

Vamos começar criando um array numpy. Um array numpy parece igualzinho a uma lista em python, mas não se engane, não é!

list_py = [1, 2, 3]

# Array numpy descolado
vector_np = np.array([1, 2, 3])

print('list python:', type(list_py), list_py)
print('vetor numpy', type(vector_np), vector_np)
list python: <class 'list'> [1, 2, 3]
vetor numpy <class 'numpy.ndarray'> [1 2 3]

Humm... okay, vamos tentar dar um append nas listas...

list_py.append(1)

# Erro!
try:
    vector_np.append(1)
except Exception as e:
    print(f'Error: {type(e)}, Message: {e}')
Error: <class 'AttributeError'>, Message: 'numpy.ndarray' object has no attribute 'append'

Não existe append diretamente em um vetor numpy... Mas você pode fazer append via numpy...

vector_np = np.append(vector_np, 1)
vector_np
array([1, 2, 3, 1])

Mas fica aquele velho ditado: não é porque você pode que você deve!

Normalmente não executamos append em vetores numpy, eles já nascem prontinhos e realizamos operações entre vetores (adição, multiplicação, transposição, ...). Não adicionamos ou removemos valores de vetores numpy.

Por que? Em geral isso é ineficiente, olha o exemplo abaixo.

Obs:Para mais detalhes da uma olhada nesses links 1, 2.

import time

N = 100000

# Usando uma lista python e depois convertendo para numpy
def time_to_create_vector_py():
    start = time.time()
    
    l = []
    for i in range(N):
        l.append(i)
        
    l = np.array(l)
    return time.time() - start

# Usando um vetor numpy desde o inicio pq sou teimoso
def time_to_create_vector_np():
    start = time.time()
    
    l = np.array([])
    for i in range(N):
        l = np.append(l, i)
        
    l = np.array(l, dtype=int)
    return time.time() - start
    
    
print('Tempo utilizando lista: %.3f (s)' % time_to_create_vector_py())
print('Tempo utilizando numpy: %.3f (s)' % time_to_create_vector_np())
Tempo utilizando lista: 0.021 (s)
Tempo utilizando numpy: 3.887 (s)

Ta, então uma vez que a gente cria um array numpy, normalmente não alteramos ele via operações convencionais de modificação de lista.

Se você para pra pensar isso faz todo sentido: já que um array numpy não é uma lista python, mas sim um tensor.

  • Vamos chamar o número de dimensões de um tensor de ndim;
  • O shape é uma tupla de inteiros do tamanho do ndim que fornece número de elementos ao longo de cada dimensão.
vector_np.ndim
1
vector_np.shape
(4,)

E se em vez de um array tivermos um escalar?

scalar_np = np.array(3)

# 0 pq não temos nenhuma dimensão
print(scalar_np.ndim)
# Vazia
print(scalar_np.shape)
0
()

E matrizes?

matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
matrix_np = np.array(matrix)

print(matrix_np.ndim)
# (3, 3) pq é uma matrix 3x3
print(matrix_np.shape)
2
(3, 3)

E outros tensores?

tensor_py = [[[1.0], [2.0], [3.0]], [[1.0], [2.0], [3.0]], [[1.0], [2.0], [3.0]]] # tensor com dimensoes: (3, 3, 1)
tensor_np = np.array(tensor_py)
print(tensor_np.ndim)
print(tensor_np.shape)
3
(3, 3, 1)

Mágica com Numpy: Broadcasting

Esse código python quebra porque não da pra somar inteiro com lista.

try:
    1 + matrix
except Exception as e:
    print(e)
unsupported operand type(s) for +: 'int' and 'list'

Mas pensa um pouco... pensando em soma de tensores... o código faz "sentido", parece que 1 deveria ser somado a cada elemento da lista.

Bem que dava pra python ser mais esperto e entender que o que a gente quer é na verdade propagar o 1 por toda a matriz somando cada elemento a 1.

Essa propagação é justamente o que chamamos de broadcasting. Que é a ideia de que vetores de tamanhos e formatos diferentes são compatíveis para certas operações em alguns casos!

matrix_np
array([[1, 2, 3],
       [4, 5, 6],
       [7, 8, 9]])
# Somar um valor a todos os elementos de uma matriz nunca foi tao fácil!
1 + matrix_np
array([[ 2,  3,  4],
       [ 5,  6,  7],
       [ 8,  9, 10]])

O que acontece se tentarmos somar um vetor de tamanho 3 a matriz?

[1, 2, 3] + matrix_np
array([[ 2,  4,  6],
       [ 5,  7,  9],
       [ 8, 10, 12]])

Sem broadcasting, como faríamos a operação acima usando numpy?

v = np.array([1, 2, 3])
print(v)

# Cria 3 copias de v e empilha
vv = np.tile(v, (3, 1))
print('-' * 10)
print(vv, vv.shape)

# Soma
print('-' * 10)
print('Soma')
matrix_np + vv
[1 2 3]
----------
[[1 2 3]
 [1 2 3]
 [1 2 3]] (3, 3)
----------
Soma
array([[ 2,  4,  6],
       [ 5,  7,  9],
       [ 8, 10, 12]])

O broadcasting nos permite obter o mesmo resultado sem precisar criar cópias do vetor. Sendo mais eficiente tanto em tempo quanto em memória. Além de simplificar bastante a nossa vida.

try:
    [1, 2] + matrix_np
except Exception as e:
    print(e)
operands could not be broadcast together with shapes (2,) (3,3) 

O que aconteceu foi que os formatos dos vetores não são compatíveis, então numpy não conseguiu realizar broadcast corretamente para realizar a operação.

O que é até intuitivo, o que danado a gente estava esperando somando um vetor de tamanho 2 com uma matriz 3x3?

Existe um algoritmo que podemos utilizar pra saber se o broadcast vai dar certo:

  • Recebemos a e b
  • Percorremos os formatos de a e b de trás pra frente
  • Para cada uma das dimensões dim_a e dim_b deve ser verdade que:
    • dim_a == dim_b ou dim_a == 1 ou dim_b == 1

Pense um pouco a respeito... e tende adivinhar os formatos de cada vetor e se os pares são passíveis de broadcast:

  • Caso 1: [1, 2] e [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
  • Caso 2: [1] e [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
  • Caso 3: [[[[[1]]]]] e [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
  • Caso 4: [1, 2] e [[1, 2], [4, 5], [7, 8]]

O código da checagem e soluções seguem logo abaixo.

def is_broadcast_possible(a, b):
    # Oferecimento: https://stackoverflow.com/questions/47243451/checking-if-two-arrays-are-broadcastable-in-python
    a, b = np.array(a), np.array(b)
    print('Formato de a:', a.shape)
    print('Formato de b:', b.shape)
    return all((m == n) or (m == 1) or (n == 1) for m, n in zip(a.shape[::-1], b.shape[::-1]))
is_broadcast_possible([1, 2], [[1, 2, 3], [4, 5, 6], [7, 8, 9]])
Formato de a: (2,)
Formato de b: (3, 3)
False
is_broadcast_possible([1], [[1, 2, 3], [4, 5, 6], [7, 8, 9]])
Formato de a: (1,)
Formato de b: (3, 3)
True
is_broadcast_possible([[[[[1]]]]], [[1, 2, 3], [4, 5, 6], [7, 8, 9]])
Formato de a: (1, 1, 1, 1, 1)
Formato de b: (3, 3)
True
is_broadcast_possible([1, 2], [[1, 2], [4, 5], [7, 8]])
Formato de a: (2,)
Formato de b: (3, 2)
True

Broadcast é possível para diferentes operações e não apenas soma:

a = np.array([1, 2])
b = np.array([[1, 2], [4, 5], [7, 8]])

print('a + b')
print(a + b)
print('a * b')
print(a * b)
a + b
[[ 2  4]
 [ 5  7]
 [ 8 10]]
a * b
[[ 1  4]
 [ 4 10]
 [ 7 16]]

Mais operações com Numpy

Criando tipos específicos de arrays

np.zeros((3, 3)) # argumento é o shape do array.
array([[0., 0., 0.],
       [0., 0., 0.],
       [0., 0., 0.]])
np.ones((3, 3))
array([[1., 1., 1.],
       [1., 1., 1.],
       [1., 1., 1.]])
np.full((3, 3), 7)
array([[7, 7, 7],
       [7, 7, 7],
       [7, 7, 7]])
np.eye(3) # matriz diagonal.
array([[1., 0., 0.],
       [0., 1., 0.],
       [0., 0., 1.]])
np.diag([1, 2, 3]) # matriz diagonal com valores específicos.
array([[1, 0, 0],
       [0, 2, 0],
       [0, 0, 3]])
np.diag([1, 2, 3], k=1) # k = offset da diagonal
array([[0, 1, 0, 0],
       [0, 0, 2, 0],
       [0, 0, 0, 3],
       [0, 0, 0, 0]])
np.mgrid[1:4, 1:4] # similar ao meshgrid no Matlab
array([[[1, 1, 1],
        [2, 2, 2],
        [3, 3, 3]],

       [[1, 2, 3],
        [1, 2, 3],
        [1, 2, 3]]])
np.random.rand(3, 3) # distribuição aleatória
array([[0.5354651 , 0.03944808, 0.09010589],
       [0.29745569, 0.49649532, 0.5062706 ],
       [0.25381761, 0.36162105, 0.89102788]])
np.random.randn(3, 3) # distribuição normal (gaussiana)
array([[-0.11642511,  0.83507777, -0.13984386],
       [ 0.63671444, -0.81420847,  0.81851364],
       [-1.27792527, -0.75325989, -1.01184543]])
np.random.randint(1, 10, (3, 3)) # número aleatórios inteiros de 1 a 10
array([[9, 4, 5],
       [4, 9, 5],
       [1, 7, 6]])

np.arange([start,] stop[,step,], dtype=None)

np.arange(10)
array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
np.arange(1,10)
array([1, 2, 3, 4, 5, 6, 7, 8, 9])
np.arange(1, 10, 0.5)
array([1. , 1.5, 2. , 2.5, 3. , 3.5, 4. , 4.5, 5. , 5.5, 6. , 6.5, 7. ,
       7.5, 8. , 8.5, 9. , 9.5])
np.arange(1, 10, 3)
array([1, 4, 7])
np.arange(1, 10, 2, dtype=np.float64)
array([1., 3., 5., 7., 9.])

np.linspace(start, stop, num=50, endpoint=True, retstep=False)

np.linspace(1, 5, num=10)
array([1.        , 1.44444444, 1.88888889, 2.33333333, 2.77777778,
       3.22222222, 3.66666667, 4.11111111, 4.55555556, 5.        ])
np.linspace(0, 2, num=4)
array([0.        , 0.66666667, 1.33333333, 2.        ])
np.linspace(0, 2, num=4, endpoint=False)
array([0. , 0.5, 1. , 1.5])

Examinando um array n-dimensional

ds = np.array([[1,2,3],[4,5,6],[7,8,9]])
ds.ndim
2
ds.shape
(3, 3)
ds.size  # número total de elementos
9
ds.dtype  # tipo dos elementos guardados
dtype('int64')
ds.itemsize  # qtde de bytes por valor
8
ds.size * ds.itemsize  # espaço total ocupado em memória (em bytes)
72

Análise Estatística

data_set = np.random.random((2, 3))
data_set
array([[0.497479  , 0.9044164 , 0.8055071 ],
       [0.94548988, 0.48197444, 0.14012832]])

np.max(a, axis=None, out=None, keepdims=False)

np.max(data_set)
0.945489878578263
np.max(data_set, axis=0)
array([0.94548988, 0.9044164 , 0.8055071 ])
np.max(data_set, axis=1)
array([0.9044164 , 0.94548988])

np.min(a, axis=None, out=None, keepDims=False)

np.min(data_set)
0.14012832288145138

np.mean(a, axis=None, dtype=None, out=None, keepdims=False)

np.mean(data_set)
0.6291658580366081

np.median(a, axis=None, out=None, overwrite_input=False)

np.median(data_set)
0.6514930500202768

np.std(a, axis=None, dtype=None, out=None, ddof=0, keepdims=False)

np.std(data_set)
0.2843367826485762

np.sum(a, axis=None, dtype=None, out=None, keepdims=False)

np.sum(data_set)
3.7749951482196487

np.prod(a, axis=None, dtype=None, out=None, keepdims=False)

prod = 1
for e in data_set.flatten():
    prod *= e
    
np.prod(data_set), prod
(0.023142984998758852, 0.023142984998758852)
prods = []
for v in data_set:
    prod = 1
    for e in v:
        prod *= e
    prods.append(prod)
    
np.prod(data_set, axis=1), prods
(array([0.36242033, 0.06385675]), [0.36242033201744756, 0.06385675127532499])

np.cumsum(a, axis=None, dtype=None, out=None)

np.cumsum(data_set)  # soma acumulada.
array([0.497479  , 1.4018954 , 2.2074025 , 3.15289238, 3.63486683,
       3.77499515])

np.cumprod(a, axis=None, dtype=None, out=None)

np.cumprod(data_set)  # multiplicação acumulada.
array([ 0.97054673,  0.34801702,  0.07301054,  0.05066202,  0.04417692,
        0.03287754])

Redimensionando Arrays

np.reshape(a, newshape, order='C')

Geralmente não modificamos o parâmetro order.

np.reshape(data_set, (3, 2))
array([[0.497479  , 0.9044164 ],
       [0.8055071 , 0.94548988],
       [0.48197444, 0.14012832]])
np.reshape(data_set, (6, 1))
array([[0.497479  ],
       [0.9044164 ],
       [0.8055071 ],
       [0.94548988],
       [0.48197444],
       [0.14012832]])
np.reshape(data_set, 6)
array([0.497479  , 0.9044164 , 0.8055071 , 0.94548988, 0.48197444,
       0.14012832])

np.ravel(a, order='C')

np.ravel(data_set)
array([0.497479  , 0.9044164 , 0.8055071 , 0.94548988, 0.48197444,
       0.14012832])
data_set.flatten() # igual ao ravel
array([0.497479  , 0.9044164 , 0.8055071 , 0.94548988, 0.48197444,
       0.14012832])

Acessando elementos

Indexação

data_set = np.random.randint(1, 10, (5, 5))
data_set
array([[1, 2, 7, 5, 5],
       [9, 9, 7, 1, 2],
       [9, 4, 4, 5, 3],
       [9, 6, 6, 5, 3],
       [7, 9, 8, 5, 1]])
data_set[1] # segunda linha
array([9, 9, 7, 1, 2])
data_set[1][0] # segunda linha, primeira coluna 
9
data_set[1, 0] # equivalente a de cima
9

Indexação por inteiros

Quando você indexa matrizes numpy usando slicing, a matriz resultante sempre será um subarray da matriz original. Por outro lado, a indexação de matrizes por inteiros permite que você construa matrizes arbitrárias usando os dados de outra matriz. Aqui está um exemplo:

a = np.array([[1,2], [3,4], [5,6]])
a
array([[1, 2],
       [3, 4],
       [5, 6]])
a[[0, 1, 2], [0, 1, 0]]
array([1, 4, 5])
np.array([a[0, 0], a[1, 1], a[2, 0]]) # equivalente ao de cima
array([1, 4, 5])

Indexação booleana

Boolean array indexing lets you pick out arbitrary elements of an array. Frequently this type of indexing is used to select the elements of an array that satisfy some condition. Here is an example:

a = np.array([[1,2], [3,4], [5,6]])
a
array([[1, 2],
       [3, 4],
       [5, 6]])
bool_idx = (a > 2)
print(bool_idx)
[[False False]
 [ True  True]
 [ True  True]]
print(a[bool_idx])
[3 4 5 6]
print(a[a > 2])
[3 4 5 6]

Slicing

data_set[2:4] # terceira e quarta linhas
array([[9, 4, 4, 5, 3],
       [9, 6, 6, 5, 3]])
data_set[2:4, 0] # terceira e quarta linhas, primeira coluna
array([9, 9])
data_set[2:4, 0:2] # terceira e quarta linhas, primeira e segunda coluna
array([[9, 4],
       [9, 6]])
data_set[:, 0] # todas as linhas, primeira coluna
array([1, 9, 9, 9, 7])

Steping

data_set[:, 0:10:2] # 1ª, 3ª, 5ª, 7ª e 9ª colunas para todas as linhas
array([[1, 7, 5],
       [9, 7, 2],
       [9, 4, 3],
       [9, 6, 3],
       [7, 8, 1]])
data_set[::]
array([[1, 2, 7, 5, 5],
       [9, 9, 7, 1, 2],
       [9, 4, 4, 5, 3],
       [9, 6, 6, 5, 3],
       [7, 9, 8, 5, 1]])
data_set[::2] # 1ª, 3ª e 5ª linha, todas as colunas
array([[1, 2, 7, 5, 5],
       [9, 4, 4, 5, 3],
       [7, 9, 8, 5, 1]])

Operações com matrizes

x = np.array([[1,2], [3,4]])
y = np.array([[5,6], [7,8]])

print(x+y)
print(np.add(x,y))
[[ 6  8]
 [10 12]]
[[ 6  8]
 [10 12]]
print(x - y)
print(np.subtract(x, y))
[[-4 -4]
 [-4 -4]]
[[-4 -4]
 [-4 -4]]
print(x * y)
print(np.multiply(x, y))
[[ 5 12]
 [21 32]]
[[ 5 12]
 [21 32]]
print(x / y)
print(np.divide(x, y))
[[0.2        0.33333333]
 [0.42857143 0.5       ]]
[[0.2        0.33333333]
 [0.42857143 0.5       ]]
print(np.sqrt(x))
[[1.         1.41421356]
 [1.73205081 2.        ]]

Observe que, diferentemente do MATLAB, $*$ é a multiplicação elementar, não a multiplicação de matrizes. Em vez disso, usamos a função dot para calcular produtos internos de vetores, multiplicar um vetor por uma matriz e multiplicar matrizes.

v = np.array([9, 10])
w = np.array([11, 12])

# produto interno
print(v.dot(w))
print(np.dot(v, w))
219
219
print(x.dot(v))
print(np.dot(x, v))
[29 67]
[29 67]
print(x.dot(y))
print(np.dot(x, y))
[[19 22]
 [43 50]]
[[19 22]
 [43 50]]
print(x.T)

print('-------------')
# ou
print(np.transpose(x))
[[1 3]
 [2 4]]
-------------
[[1 3]
 [2 4]]

Lista de todas as operações: Documentação de Numpy.

Voltando a falar de redes neurais...

Depois de ler isso tudo sobre tensores, você deve estar pensando:

"Beleza, mas... como que redes neurais usam esses tais tensores?".

Por exemplo:1. As entradas podem ser representadas por tensores bi-dimensionais (matrizes), onde cada linha dessa matriz vai representar uma amostra de uma base, equanto cada coluna representa um atributo (também chamada de feature). Por exemplo, no seguinte banco de dados: Nós temos 5 amostras (5 linhas) e 4 atributos (sepal length, sepal width, petal length e petal width) - a coluna target nesse banco representa um outro atributo que estamos interessados em identificar para cada amostra.

  1. Considerando imagens as entradas vão ser agora representadas por tensores 4-dimensionais. Em geral, a maioria dos frameworks assumem que esses tensores estão no formato NxHxWxC, onde:

    • N: representa a quantidade de imagens no seu banco
    • H: a altura de cada imagem
    • W: a largura de cada imagem
    • C: a quantidade de canais de cada imagem. Imagens em níveis de cinza têm apenas 1 canal, enquanto imagens coloridas possuem 3 canais - vermelho (R), verde (G) e azul (B).

      Também é comum ver tensores no formato NxCxHxW, ou seja, os canais da imagem vêm logo após a quantidade de imagens.