12. Tipo ndarray do módulo NumPy

12. Tipo ndarray do módulo NumPy

12. Tipo ndarray do módulo NumPy

A medida que escrevemos nossas próprias classes e funções, podemos acelerar o desenvolvimento de outros programas reutilizando as funções e módulos já escritos e testados. Nessa aula veremos como podemos organizar essas classes e funções em módulos.

Para conseguir executar os exercícios dessa aula em seu computador você precisa primeiro instalar o NumPy em sua máquina, caso você ainda não o tenha feito. Para isso, recomendamos que você instale o pacote Anaconda. O Anaconda é um software livre para estudantes que inclui mais de 300 pacotes do Python voltados para ciência, matemática, engenharia e análise de dados.

Como utilizamos a versão 3.x do Python nesse curso, instale a versão apropriada para o Python 3.x.

12.1. Objetivos de aprendizagem

Ao final dessa classe você será capaz de:

  • utilizar módulos em seus programas;
  • criar objetos do tipo ndarray;
  • acessar elementos e fatias de ndarrays;
  • definir o que são vistas e cópias de arrays.

12.2. Introdução

Na aula anterior começamos a introduzir o conceito de array por ser um tipo bastante utilizado em computação científica. Vimos que arrays são estruturas de dados semelhantes às listas do Python, mas não tão flexíveis. Em um array todos os elementos devem ser de um mesmo tipo, tipicamente numérico, como int ou float. Além disso, o tamanho de um array não pode ser modificado, ao contrário de listas que podem crescer dinamicamente. Em contrapartida, o uso de arrays é muito mais eficiente e facilita a computação de grandes volumes de dados numéricos.

Para trabalhar com arrays em Python podemos utilizar o módulo NumPy (Numeric Python). O NumPy define a classe ndarray para representar arrays de \(n\) dimensões.

Além disso, o NumPy oferece várias funções pré-compiladas que tornam o processamento de arrays bastante eficiente. Aqui vamos introduzir apenas um pequeno conjunto desses recursos. Para saber mais, consulte a documentação do [módulo NumPy](https://www.numpy.org/doc). Parte do conteúdo desse texto foi baseado no capítulo 2 do NumPy User Guide.

12.3. Revisão: como usar módulos em Python

Antes de falar mais sobre o módulo NumPy, vamos relembrar (ou introduzir, caso você não tenha visto no curso de introdução) como usar módulos em Python.

É provável que você já conheça e tenha usado alguns módulos da biblioteca padrão do Python, como o módulo matemático math e de números pseudo-aleatórios random. Para usar esses módulos, devemos primeiro carregá-los usando o comando import e, para utilizar as funções definidas dentro do módulo precisamos indicar o módulo e a função separados por um ponto, como ilustrado no trecho de código abaixo:

import math   # carrega o módulo math

print(f"math.pi = {math.pi}")  ## constante pi definida no módulo math
print(f"cos(pi/2) = {math.cos( math.pi /2 )}")

O uso de módulos é bastante útil para reusar suas próprias classes e funções, organizando-as em arquivos separados. Por convenção, vamos armazenar nossas classes em arquivos com o mesmo nome da classe mas usando todas as letras minúsculas e com a extensão “.py”, como começamos a fazer na atividade da aula anterior. Você deve se lembrar que nossas classes começam com letra maiúscula, como Fraction, Complexo e Array1D. Assim, essas classes seriam armazenadas nos arquivos “fraction.py”, “complexo.py” e “array1d.py”.

Para reusar essas classes é necessário colocar os arquivos correspondentes em um lugar conhecido pelo Python como, por exemplo, na mesma pasta do programa principal. Dessa forma o comando import vai conseguir localizar e carregar o arquivo.

O Trinket abaixo ilustra a classe Array1D que foi escrita no módulo “array1d.py”. Observe que no arquivo “array1d.py” temos uma função main() que não é executada ao ser carregada no programa dentro do arquivo “main.py”. Isso ocorre pois a chamada da main() em “array1d” está dentro do if:

if __name__ == "__main__":
    main()

Com isso, o código dentro do if só é executado quando o arquivo “array1d.py” é executado como programa principal e não quando é carregado como módulo.

Remova a linha if __name__ == "__main__": e acerte a tabulação da chamada da função “main()” do arquivo “array1d.py” e execute o programa para ver o que ele faz agora.

12.4. Motivação para o uso de tuplas para acessar elementos de um array 2D

Na aula anterior vimos como usar uma lista linear para armazenar dados de uma matriz 2D. Vimos também que é possível acessar os elementos de uma Array2D usando uma tupla. Você pode estar se perguntando: mas qual a vantagem disso?

Vamos primeiro lembrar como se comporta uma matriz representando usando listas aninhadas. Clique no botão Forward > para executar o trecho de código passo-a-passo.

Esse trecho de código ilustra que podemos acessar diretamente os elementos de uma linha de uma “matriz representada usando listas aninhadas”. A variável lin2 faz referência à linha 2 de m32 e por isso pode ser usada para modificar o conteúdo da linha (e da matriz). Essas referências não são cópias e por isso, ao modificar uma das referências, todas as demais variáveis são afetadas.

Observe que o acesso as colunas não é tão simples pois o segundo par de colchetes em m32[2][1] equivale ao índice de uma linha como em lin2[1], ou seja, os colchetes são aplicados em sequência. Uma forma de acessar uma coluna é por meio de uma tupla capaz de considerar as duas dimensões simultaneamente (ao invés de em sequência). Por exemplo, gostaríamos de ter acesso a coluna 1 de m32 usando uma notação de fatias em Python, como por exemplo m32[:,1], ou seja, uma tupla que considera todas as linhas e a coluna de índice 1, simultaneamente. Você pode ler mais sobre fatias em Fatias de listas.

Embora seja possível implementar essa forma de acesso estendendo o comportamento da classe Array2D, vamos já demonstrar esses recursos usando o tipo ndarray do módulo NumPy.

12.5. O que é um ndarray?

Considere a classe Array2D como uma introdução ao tipo ndarray. Assim como o Array2D, um ndarray também possui os atributos data, shape e size, além de aceitar tuplas de inteiros positivos para acessar seus elementos. O tipo ndarray na verdade permite criar de \(n\) dimensões. Vamos chamar essas de eixos (= axes).

Por ser uma classe básica do NumPy, o ndarray também recebe o apelido de array. No entanto note que o numpy.array é diferente do tipo nativo do Python array.array que só manipula arrays de uma dimensão e apresenta menos funcionalidades.

12.5.1. Principais atributos do tipo ndarray

  • ndarray.data: referência para a área de memória onde os dados do array ficam realmente armazenados. Em geral não utilizamos esse atributo, pois utilizamos índices para acessar os elementos.
  • ndarray.shape: uma tupla contendo as dimensões do array, ou comprimento do array em cada eixo. O comprimento dessa tupla é o número de dimensões ndim.
  • ndarray.size: número total de elementos no array. Equivale ao produto dos elementos de shape.
  • ndarray.ndim: um inteiro que corresponde ao número de eixos (dimensões) do array.
  • ndarray.dtype: descreve o tipo dos elementos do array. Além dos tipos nativos do Python, como int e float, o NumPy oferece seus próprios tipos, como numpy.int32, numpy.int16, numpy.float64, etc.
  • ndarray.itemsize: indica o tamanho de cada elemento do array em bytes. Por exemplo, para elementos do tipo numpy.float64, o tamanho em 8 bytes (= 64 bits / 8 bits).

12.6. Como criar um ndarray

O trecho de código abaixo ilustra como podemos criar ndarrays do NumPy a partir de listas:

Lembre-se que, para usarmos um módulo, precisamos carregá-lo usando import. No exemplo, o módulo foi carregado e usamos o apelido (abreviação) np para acessar os recursos desse módulo.

Observe que a variável a faz referência a uma matriz de dimensão (2,3), e essa matriz é utilizada para criar um array de mesma dimensão e conteúdo usando b = np.array(a), referenciada pela variável b. É possível também especificar o tipo do array, como ilustrado no exemplo c = np.array(a, float). Estenda o código para imprimir o valor de outros atributos de um ndarray como shape, size etc.

É também possível criar um array com valor inicial usando np.full() como a seguir.

>>> z = np.full( (2,3), 0.0 ) # dimensao 2x3
>>> u = np.full( (3,2), 1)
>>> id = np.identity( 4, int )
>>> print(z)
[[ 0.  0.  0.]
[ 0.  0.  0.]]
>>> print(u)
[[1 1]
[1 1]
[1 1]]
>>> print(id)
[[1 0 0 0]
[0 1 0 0]
[0 0 1 0]
[0 0 0 1]]
>>> z.dtype
dtype('float64')
>>> z.shape
(2, 3)
>>> z.ndim
2
>>> z.size
6

Escreva esse código no Trinket acima para verificar o que acontece.

Observe que o array de zeros z tem dimensão (2,3) (= 2 por 3) enquanto o array array u com 1``s tem dimensão ``(3,2) (= 3 por 2). Os tipos int ou float são definidos pelo próprio valor passado na criação do array. Outra função útil para criar matrizes identidade (que tem diagonal igual a 1) é a função np.identity(). Como a matriz identidade deve ser quadrada, é necessário fornecer apenas uma dimensão (no caso 4) e o tipo desejado (parâmetro opcional, o default é float).

Uma outra maneira conveniente para se criar um array a partir de uma lista linear é redimensionando o array usando o método reshape() da classe ndarrays:

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

Nesse caso, a função range() é utilizada para criar uma lista de números de 0 a 11, que é redimensionada para uma matriz de dimensão (3,4).

12.7. Índices e fatias de arrays

Para acessar um elemento de uma lista de dimensão n é necessário fornecer o valor de n índices, ou seja, 2 índices para uma matriz bidimensional, 3 índices para uma matriz tridimensional e assim por diante.

Vamos considerar primeiro o caso unidimensional, que requer apenas 1 índice para acessar um elemento. Nesse caso arrays se comportam de forma muito semelhante às listas, como podemos ver nos exemplos a seguir.

>>> li = [1, 2, 3, 4, 5, 6]
>>> ar = np.array(li)
>>> li
[1, 2, 3, 4, 5, 6]
>>> ar
array([1, 2, 3, 4, 5, 6])
>>> li[2]
3
>>> ar[2]
3
>>> ar[2:5]         # fatia [2,5)]
array([3, 4, 5])
>>> ar[:5:2]        # fatia até o elemento de índice 5, com passo de 2 em 2
array([1, 3, 5])
>>> ar[::-1]        # array invertido, usando passo -1
array([6, 5, 4, 3, 2, 1])
>>> for i in ar:         # para cada elemento i do array ar
...     print(i ** 2)
...
1
4
9
16
25
36
>>>

No caso multidimensional, embora seja possível usar a mesma forma utilizada em listas para acessar elementos de um array usando um par de colchetes para cada índice, uma forma mais poderosa é separando os valores por vírgulas dentro de um único par de colchetes, como a seguir:

>>> a = [[0, 1, 2, 3, 4], [5, 6, 7, 8, 9]]
>>> a
[[0, 1, 2, 3, 4], [5, 6, 7, 8, 9]]
>>> a[0,0]
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: list indices must be integers, not tuple
>>> a[0][0]
0
>>> b = np.array(a)
>>> b[1][2]
7
>>> b[1,2]
7

Além de ser mais eficiente, o uso de vírgulas permite formas de fatiamento mais flexíveis, como mostrado nos exemplos a seguir.

>>> lista = [[0, 1, 2, 3], [4, 5, 6, 7], [8, 9, 10, 11]]
>>> lista
[[0, 1, 2, 3], [4, 5, 6, 7], [8, 9, 10, 11]]
>>> lista[1:-1]
[[4, 5, 6, 7]]
>>> mat = np.array(lista)
>>> mat
array([[ 0,  1,  2,  3],
       [ 4,  5,  6,  7],
       [ 8,  9, 10, 11]])
>>> mat[1:-1]
array([[4, 5, 6, 7]])
>>> mat[:,2]
array([ 2,  6, 10])
>>> mat[:,2:]
array([[ 2,  3],
       [ 6,  7],
       [10, 11]])
>>>

Você pode executar e modificar esses exemplos no Trinket abaixo:

Note que a variável lista faz referência a uma matriz (lista de listas) de dimensão (3,4) e que a fatia lista[1:-1] é a matriz contendo apenas a segunda linha da matriz. Lembre-se que é possível usar índices negativos para indicar os elementos da direita para a esquerda, e portanto o último elemento pode ser indicado pelo índice -1, o penúltimo por -2, etc.

Quando um array é criado com os elementos da lista o mesmo resultado pode ser obtido usando o fatiamento mat[1:-1]. No entanto, usando vírgulas, podemos selecionar todas as linhas de mat e a coluna de índice 2 usando mat[:,2] (= coluna de índice 2) e ainda criar uma matriz (agora um array) contendo as duas últimas colunas usando mat[:,2:].

De volta ao nosso problema de motivação, veja que o uso de array simplifica em muito o acesso a colunas e outros pedaços do array.

12.8. Fatias de arrays são vistas

Diferente das fatias de listas em Python, as fatias de arrays não retornam clones (cópias) dos elementos, mas apenas vistas diferentes dos mesmos dados. Isso significa que, se os elementos de uma vista forem alterados, isso resulta na alteração dos dados em todas as vistas desses dados! Para evitar esse efeito, é necessário criar cópias explicitamente dos arrays desejados usando o método copy(), como ilustrado nos exemplos abaixo.

>>> ar = np.arange(30).reshape(5,6)   # cria um array com valores de 0 a 29, no formato (5,6)
>>> print(ar)
[[ 0  1  2  3  4  5]
[ 6  7  8  9 10 11]
[12 13 14 15 16 17]
[18 19 20 21 22 23]
[24 25 26 27 28 29]]
>>> pedaco = ar[1:4, :-3]       # tira um pedaço de ar
>>> print(pedaco)
[[ 6  7  8]
[12 13 14]
[18 19 20]]
>>> copia = pedaco.copy()       # faz uma cópia do pedaço
>>> print(copia)
[[ 6  7  8]
[12 13 14]
[18 19 20]]
>>> copia[1,1] = -1
>>> print(copia)            # muda um elemento em copia
[[ 6  7  8]
[12 -1 14]
[18 19 20]]
>>> print(pedaco)           # a mudança não altera o pedaço
[[ 6  7  8]
[12 13 14]
[18 19 20]]
>>> pedaco[1,1] = -1
>>> print(pedaco)           # muda um elemento no pedaço
[[ 6  7  8]
[12 -1 14]
[18 19 20]]
>>> print(ar)               # a mudança altera o array ar
[[ 0  1  2  3  4  5]
[ 6  7  8  9 10 11]
[12 -1 14 15 16 17]
[18 19 20 21 22 23]
[24 25 26 27 28 29]]
>>> pedaco[:,:] = 99        # é possível mudar todos os elementos de uma vez
>>> print(pedaco)
[[99 99 99]
[99 99 99]
[99 99 99]]
>>> print(ar)               # e a mudança pode ser vista também no array ar
[[ 0  1  2  3  4  5]
[99 99 99  9 10 11]
[99 99 99 15 16 17]
[99 99 99 21 22 23]
[24 25 26 27 28 29]]
>>>

Utilize o seguinte trecho de código no Trinket para explorar as diferenças entre vistas e cópias.

12.9. Exercícios

Nesses exercícios, ainda não vamos utilizar o tipo ndarrray para que possamos explorar e fixar melhor os conceitos de cópia e vista de objetos.

Nesse exercício você deve implementar mais dois métodos da classe Array2D: copy() and reshape().

O método copy() retorna uma cópia completa de um objeto do tipo Array2D, ou seja, incluindo uma cópia da lista data do objeto.

Já o método reshape() recebe uma tupla com uma dimensão (nlin, ncol) e retorna uma vista do objeto do tipo Array2D. A vista se comporta como um objeto Array2D com a nova dimensão, ou seja, com valores diferentes do atributo shape, mas compartilha a mesma lista data.

Para entender o comportamento esperado de vistas e cópias, estude o trecho de programa abaixo e o resultado dos comandos print().

print("Testes da classe Array2d\n")

a = Array2d( (1,6), 3) # cria Array2d com valor inicial 3
print(f'teste 1: Criação do Array2d a:')
print(a)
print()

b = a.reshape( (2,3) )
print(f'teste 2: reshape cria uma vista')
print(b)
print()

print(f'teste 3: mudanças em b devem resultar em mudanças em a:')
b[1, 2] = 100
print(a)
print(b)
print()

print(f'teste 4: e vice-versa - mudanças em a devem resultar em mudanças em b:')
a[0, 2] = -1
print(a)
print(b)
print()

print(f'teste 5: copy cria um clone')
a = Array2d( (1,6), 3) # cria Array2d com valor inicial 3
c = a.copy()
print(f'a: {a}')
print(f'c: {c}')
print()

print(f'teste 6: mudanças em objeto um não devem refletir no outro')
a[0,1] = 99
c[0,5] = -1
print(f'a: {a}')
print(f'c: {c}')
print()

Saída dos comandos print():

Testes da classe Array2d

teste 1: Criação do Array2d a:
3 3 3 3 3 3

teste 2: reshape cria uma vista
3 3 3
3 3 3

teste 3: mudanças em b devem resultar em mudanças em a:
3 3 3 3 3 100
3 3 3
3 3 100

teste 4: e vice-versa - mudanças em a devem resultar em mudanças em b:
3 3 -1 3 3 100
3 3 -1
3 3 100

teste 5: copy cria um clone
a: 3 3 3 3 3 3
c: 3 3 3 3 3 3

teste 6: mudanças em um objeto não devem refletir no outro
a: 3 99 3 3 3 3
c: 3 3 3 3 3 -1

12.10. Onde estamos e para onde vamos?

Arrays são estruturas muito utilizadas em computação científica. Nessa aula começamos a introduzir o tipo ndarray do NumPy e, basicamente, vimos como criar e acessar seus elementos e fatias. Um conceito importante ao usar fatias de arrays (ndarrays) é que essa fatias são vistas e não cópias. Embora essas vistas sejam mais eficientes por não replicarem os dados, é necessário ter cuidado sobre os resultados das operações usando vistas.

Na próxima aula vamos ver mais detalhes e um pouco mais do poder que podemos ganhar usando ndarrays.