13. Mais sobre arrays

Nessa aula vamos passar a usar o ndarray do módulo NumPy e esconder um pouco sua implementação e representação interna que foi o foco das aulas anteriores quando desenvolvemos a classe Array2D.

13.1. Objetivos de aprendizagem

Realize seus estudos para que, ao final dessa aula, você seja capaz de:

  • acessar elementos e fatias de ndarrays;
  • realizar operações elemento-a-elemento com ndarrays;
  • criar e usar objetos do tipo ndarray em seus programas.

13.2. Introdução

Na aula anterior, interrompemos a nossa descrição do tipo ndarray para reforçar os conceitos de vista e cópia de arrays. Como as fatias de arrays geram vistas (ou seja, não geram cópias como as fatias de listas), o entendimento desses conceitos é fundamental para entender o comportamento das operações com arrays e prever os resultados das operações para que você possa utilizá-las de forma correta em seus programas.

13.2.1. Vistas e cópias de Array2D

Os conceitos de vista e cópia foram treinados nos exercícios e atividade da aula anterior ao desenvolver os seguintes métodos da classe Array2D:

  • def copy(self): retorna um Array2D, cópia de self.
  • def reshape(self, tupla): retorna um Array2D, vista de self, com novo formato (shape) definido pelo parâmetro tupla.
  • def carregue_vista(self, lista):
  • def carregue_copia(self, lista):

O Trinket abaixo ilustra uma implementação desses métodos.

Observe que na definição da classe Array2D que a criação de vistas nos métodos reshape() e carregue_vista implica em modificar o atributo data para compartilhar os dados. Dessa forma, uma alteração de um elemento da lista data é “vista” em todas as demais vistas.

O método copy(self) cria uma nova lista, incialmente com conteúdo idêntico a self. Apesar de serem inicialmente ‘iguais’, os dados são armazenados separadamente. Ao mudar o valor de um elemento de self essa mudança não será “vista” na cópia e, portanto, deixarão de ser cópias uma da outra.

Finalmente, observe que o método carregue_copia(self, lista) não cria uma nova lista mas varre lista e copia, elemento-a-elemento, os valores para o atributo self.data.

E por que não fazer, por exemplo, self.data = lista[:], que cria uma cópia de lista e associa a self.data? Essa forma não é recomendada pois, nesse caso, perderíamos as vistas já existentes de self pois essas vistas continuariam a fazer referência à lista antiga.

Escreva testes para carregue_copia() e carregue_vista() na função main() do Trinket acima.

13.3. Mais sobre o uso de tuplas para acessar elementos de um array

Observe como a classe Array2D definida acima implementa os métodos especiais __getitem__() e __setitem__(). Esses métodos recebem tuplas para selecionar um elemento do array. Assim, dado uma Array2D ar, devemos usar a notação ar[3,2] para acessar o elemento na linha de índice 3 e coluna de índice 2, ao invés de usar pares de colchetes como usados em uma matriz aninhada ma, como em ma[3][2].

Para acessar um elemento apenas, essa notação usando tuplas não traz nenhum grande benefício pois o comportamento é exatamente o mesmo. No entanto, o uso de tuplas dá muito mais flexibilidade e poder para selecionar uma região retangular de arrays usando fatias. A começar, por exemplo, com a possibilidade de indicar colunas, o que não é possível usando apenas fatias de matrizes representadas usando listas aninhadas.

Para ilustrar essa características, considere as funções inverta_linhas() e inverta_colunas() abaixo, para inverter as linhas e as colunas de um array. Essas funções mostram como um vista de uma linha e coluna podem ser criadas e copiadas. Observe que a vista pode inclusive ser usada do lado esquerdo de uma atribuição. Nesse caso a fatia do array é tratada como uma região que pode receber os valores de outro array de mesmo tamanho elemento-a-elemento.

Essa forma de atribuição múltipla para fatias de arrays é bastante útil, como ilustrado no trecho de código abaixo.

13.4. Impressão de listas aninhadas e arrays

Observe o resultado dos prints abaixo:

>>> import numpy as np
>>> A = [ [1,2,3,4], [5,6,7,8] ]
>>> print( A )
[[1, 2, 3, 4], [5, 6, 7, 8]]
>>>
>>> B = np.array(A)
>>> print(B)
[[1 2 3 4]
[5 6 7 8]]
>>>

Note que a lista A é impressa em uma única linha, enquanto print(B) mostra a estrutura do array, imprimindo cada linha separadamente.

É possível criar ndarray``s com 2, 3 ou mais dimensões. Ao imprimir arrays de ``n dimensões, a impressão obedece a mesma ordem dos índices. Assim, um array 3D seria impresso da seguinte forma:

>>> tri = np.array( range(24) ).reshape(2,3,4)
>>> print(tri)
[[[ 0  1  2  3]
[ 4  5  6  7]
[ 8  9 10 11]]

[[12 13 14 15]
[16 17 18 19]
[20 21 22 23]]]

>>> bi = tri.reshape((4,6))
>>> print(bi)
[[ 0  1  2  3  4  5]
[ 6  7  8  9 10 11]
[12 13 14 15 16 17]
[18 19 20 21 22 23]]

Nesse exemplo um array de uma dimensão é criado com os inteiros de 0 a 23 (resultado do range(24)). Uma forma alternativa seria usar diretamente o método np.arrange(24).

O método reshape() permite alterar as dimensões do array, sem alterar os dados, de forma similar ao comportamento da classe Array2D. Para isso o produto dos argumentos usados no reshape, nesse caso a tupla (2,3,4), deve ser igual ao número de elementos do array (2x3x4 = 24). Como resultado são impressas 2 matrizes de 3 linhas e 4 colunas cada, que representa o conteúdo do array de 3 dimensões.

Observe que podemos usar reshape() para criar uma vista do array com outro formato, como no exemplo, uma array $4times6$ de nome bi.

Uma outra conveniência fornecida para arrays muito grandes é a impressão apenas dos cantos, como no exemplo abaixo. Você pode modificar esse comportamento pelo método set_printoptions.

>>> grande = np.arange(10000).reshape(100,100)
>>> print(grande)
[[   0    1    2 ...   97   98   99]
[ 100  101  102 ...  197  198  199]
[ 200  201  202 ...  297  298  299]
...
[9700 9701 9702 ... 9797 9798 9799]
[9800 9801 9802 ... 9897 9898 9899]
[9900 9901 9902 ... 9997 9998 9999]]
>>>

13.5. Manipulando as dimensões de um array

As dimensões de um array podem ser manipuladas criando vistas diferentes usando, por exemplo, os métodos ravel() e reshape() e o atributo T, como ilustrado no trecho de código a seguir.

>>> bi = np.arange(6).reshape(2,3)
>>> bi
array([[0, 1, 2],
    [3, 4, 5]])
>>> bi.ravel()
array([0, 1, 2, 3, 4, 5])
>>> bi
array([[0, 1, 2],
    [3, 4, 5]])
>>> bi.T
array([[0, 3],
    [1, 4],
    [2, 5]])
>>> bi
array([[0, 1, 2],
    [3, 4, 5]])
>>> bi.T.shape
(3, 2)
>>> bi.shape
(2, 3)
>>> bi.resize(3,2)
>>> bi
array([[0, 1],
    [2, 3],
    [4, 5]])
>>>

Esse exemplo mostra a criação de um array 2D referenciado por bi. O método ravel() retorna uma vista (view) 1D do array, mas sem modificar o seu conteúdo. O atributo T retorna a transposta do array e também não modifica o array bi. As dimensões do array podem ser alteradas definitivamente pelo método resize().

Ao usar métodos que retornam vistas do array lembre-se que, apesar de possuírem dimensões (shape) diferentes, seu conteúdo no atributo data é o mesmo, ou seja, todos compartilham os mesmos dados. Apesar desse compartilhamento tornar o processamento mais eficiente pois se evita a cópia dos dados, isso pode ser indesejável em certas situações. Utilize o método copy() sempre que precisar criar arrays com dados separados. As cópias, ao serem modificadas, deixam de ser cópias pois os arrays originais não são alterados.

13.6. Operações básicas

Apesar da semelhança do tipo numpy.ndarray com as listas de Python, o NumPy oferece muito mais recursos para operação e manipulação de arrays que facilitam a computação científica.

Em ciência da computação, vetorização (vectorization) é a generalização das operações em escalares a vetores e matrizes de várias dimensões. Linguagens de programação que aplicam de maneira transparente e concisa operação sobre arrays são chamadas de array programming languages. Exemplos de linguagens que admitem vetorização são GNU Octave, Julia, R e Python NumPy.

Os arrays do NumPy permitem usar os símbolos das operações básicas como *, +, etc, para calcular um novo array “por elemento”, como ilustrado no trecho de código abaixo.

>>> import numpy as np
>>> a = np.array([[1,2],[3,4]])
>>> a
array([[1, 2],
       [3, 4]])
>>> b = np.array([[0,2],[3,1]])
>>> b
array([[0, 2],
       [3, 1]])
>>> a+b
array([[1, 4],
       [6, 5]])
>>> a*b
array([[0, 4],
       [9, 4]])
>>> b/a
array([[ 0.  ,  1.  ],
       [ 1.  ,  0.25]])

Observe que não basta ter o mesmo número de elementos, mas a forma (shape) do array também é importante, como mostra o exemplo a seguir.

>>> import numpy as np
>>> a = np.array([[1,2],[3,4]])
>>> a.shape
(2, 2)
>>> c = np.array([5,6,7,8])
>>> c
array([5, 6, 7, 8])
>>> c.shape
(4, )
>>> a + c
Traceback (most recent call last):s
  File "<stdin>", line 1, in <module>
ValueError: operands could not be broadcast together with shapes (2,2) (4,)
>>>

13.6.1. Operações entre arrays de tamanhos distintos

Em muitos casos, é necessário trabalhar com arrays com um número distinto de elementos, ou de formas diferentes. Nesse caso, é necessário entender as regras do NumPy que difundem as informações do array menor para o array maior.

O termo difusão (broadcasting) descreve como NumPy trata arrays de dimensões diferentes em operações aritméticas. O array menor é “difundido” a uma array maior para que tenham as mesmas dimensões. No caso de um escalar, o valor do escalar é difundido para todos os elementos do array maior, como no exemplo:

>>> import numpy as np
>>> a = np.array([[1,2],[3,4]])
>>> a
array([[1, 2],
    [3, 4]])
>>> a + 1
array([[2, 3],
    [4, 5]])
>>> a * 2
array([[2, 4],
    [6, 8]])
>>> a / 2
array([[ 0.5,  1. ],
    [ 1.5,  2. ]])
>>>

No caso de arrays com uma das dimensões unitárias (um array linha ou coluna por exemplo), mas onde as demais dimensões sejam compatíveis com a do array maior, o array menor é difundido para a outra dimensão do array maior. Veja os exemplos abaixo.

>>> import numpy as np
>>> a = np.array(range(1,13,2)).reshape(2,3)
>>> a
array([[ 1,  3,  5],
    [ 7,  9, 11]])
>>> b = ([4,5,6])
>>> a - b
array([[-3, -2, -1],
    [ 3,  4,  5]])
>>> c = np.array([[0],[1]])
>>> c
array([[0],
    [1]])
>>> c.shape
(2, 1)
>>> a * c
array([[ 0,  0,  0],
    [ 7,  9, 11]])
>>> d = np.array([1,2])
>>> a * d
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ValueError: operands could not be broadcast together with shapes (2,3) (2,)
>>> b * c
array([[0, 0, 0],
    [4, 5, 6]])
>>> c + b
array([[4, 5, 6],
    [5, 6, 7]])
>>>

Observe que nos dois últimos exemplos (b*c e b+c), quando a operação envolve dois arrays com uma dimensão unitária, por exemplo, (1,3) e (2,1), o resultado é um array de dimensão (2,3).

13.7. Outras funções do NumPy

Talvez você se sinta incomodado que o operador * não calcula o produto de duas matrizes, mas o produto elemento a elemento de dois arrays de mesma dimensão. Felizmente, o NumPy oferece vários outros recursos como para:

  • Álgebra Linear

    O produto de matrizes pode ser calculado usando o método dot

    >>> import numpy as np
    >>> a = np.array(range(1,13,2)).reshape(2,3)
    >>> a
    array([[ 1,  3,  5],
        [ 7,  9, 11]])
    >>> b = np.array(range(11,-1,-1)).reshape(3,4)
    >>> b
    array([[11, 10,  9,  8],
        [ 7,  6,  5,  4],
        [ 3,  2,  1,  0]])
    >>> a.dot(b)
    array([[ 47,  38,  29,  20],
        [173, 146, 119,  92]])
    >>> b.dot(a)
    Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
    ValueError: shapes (3,4) and (2,3) not aligned: 4 (dim 1) != 2 (dim 0)
    >>>
    

    Além de calcular o produto de vetores e matrizes, o NumPy oferece também vários outros recursos para inversão de matrizes e solução de sistemas lineares, cálculo de normas, de auto-valores e auto-vetores, etc. Para saber mais, consulte a documentação sobre os recursos de Álgebra Linear.

  • Estatística

    Rotinas estatísticas para cálculo de média, variância e desvio padrão, entre outras, além de funções para vários tipos de amostragem, inclusive um gerador de números pseudo-aleatórios.

    Matemática

    Diversas funções matemáticas como sqrt, log, exp, min, max, sum, tan, sin, cos, etc.

  • e muitas outras

    Como funções financeiras, Fast Fourier Transform, polinômios, ordenação, etc. Dê uma olhada no conjunto de rotinas do NumPy em http://docs.scipy.org/doc/numpy-dev/reference/routines.html.

13.8. Exercícios

O Jogo da Vida (The Game of Life) é um autômato celular (cellular automaton) introduzido por John Horton Conway em 1970. Um automato celular consiste de uma rede de células. Cada célula pode estar em um número finito de estados, como morta ou viva. O “jogo” é na verdade uma simulação que permite observar a evolução de um processo a partir de uma certa condição inicial.

O jogo se desenvolve sobre uma matriz bi-dimensional que pode ser tão grande quanto se queira. Vamos chamar essa matriz de mapa. Cada posição ou célula do mapa pode estar vazia (= célula morta) ou ocupada por um agente (= célula viva). Cada posição possui também até 8 posições vizinhas: imediatamente acima, abaixo, aos lados e nas diagonais. Em um determinado instante, o mapa contém uma geração de agentes. A geração no instante seguinte é determinada segundo as regras abaixo:

  • se uma célula [i,j] está vazia então:
    • um novo agente nasce em [i,j] se essa célula possui exatamente 3 agentes vizinhos;
  • se uma célula [i,j] possui um agente então:
    • o agente em [i,j] sobrevive se possui 2 ou 3 agentes vizinhos;
    • o agente em [i.j] morre se possuir menos de 2 agentes vizinhos, por falta de recursos;
    • o agente em [i.j] morre se possuir mais de 3 agentes vizinhos, por excesso de competição.

Supondo que um mapa M é representado por um ndarray onde células vazias tem valor 0 (zero) e agentes tem valor 1 (um), escreva a seguinte função:

def iteracao(M):
    ''' (mapa) -> None
    Recebe uma mapa (ndarray) com a geração de agentes em um determinado
    instante e atualiza o mapa de tal forma que represente geração de
    agentes no instante seguinte.
    '''

A cada iteração, o mapa deve ser varrido e, para cada célula, a função deve calcular o seu novo valor baseado nas regras do jogo. Observe que o próximo estado do mapa (no instante seguinte) depende exclusivamente do estado (no instante) atual.

Veja alguns padrões produzidos pelo jogo da vida em Glider. Variantes do jogo da vida são Day and Night, Highlife, Life without Death e Seeds.

Dicas:

  1. o método sum() calcula a soma dos elementos de um array e pode ser aplicado

    em fatias de arrays. Por exemplo, execute o seguinte trecho de código para ver o resultado.

    import numpy as np
    x = np.array( range(12) ).reshape(3,4)
    print(x)
    print(x[1:3,1:3])
    print(x[1:3, 1:3].sum())
    
  2. Teste usando ndarrays bem pequenos.

13.9. Onde estamos e para onde vamos?

Arrays são estruturas muito utilizadas em computação científica. Nessa aula começamos a abstrair a implementação para usar essas estruturas como mapas ou matrizes e manipulando partes dessas estruturas usando fatias e operações com arrays.

Nas próximas aulas vamos continuar esse processo de abstração para fortalecer seu domínio sobre arrays e operações com arrays.