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. 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. .. * realizar operações elemento-a-elemento com ``ndarrays``; * prever o comportamento de ``ndarrays`` para operações básicas e diferenciá-las de listas aninhadas. 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 :math:`n` dimensões. .. Os arrays do NumPy tem tamanho fixo, assim, para se mudar o tamanho de um ndarray, cria-se um novo array e o original é removido. 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 `__. 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: .. code:: python 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``: .. code:: python 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. 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. .. raw:: html .. cscircles .. code:: python m32 = [ [2,4], [3,5], [6,8] ] # 3 linhas e 2 colunas lin2 = m32[2] # lin2 faz referência a linha 2 de m32 print(f"lin2 = {lin2}") v21 = m32[2][1] # v21 faz referência ao inteiro 8 print(f"v21 = {v21}") v21 = -11 # v21 faz referência ao inteiro -11 print(f"v21 = {v21}") print(f"m32 = {m32}") # m32 não é modificada lin2[1] = -22 # lin2[1] faz referência ao inteiro -22 print(f"v21 = {v21}") # não modifica v21 print(f"lin2 = {lin2}") # modifica o conteúdo de lin2 print(f"m32 = {m32}") # modifica o conteúdo de m32 m32[2][1] = -33 print(f"v21 = {v21}") # não modifica v21 print(f"lin2 = {lin2}") # modifica o conteúdo de lin2 print(f"m32 = {m32}") # modifica o conteúdo de m32 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. 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 :math:`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. 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). Como criar um ndarray ---------------------- O trecho de código abaixo ilustra como podemos criar ndarrays do NumPy a partir de listas: .. raw:: html .. trinket .. code:: python >>> import numpy as np # tipicamente usamos o apelido np >>> a = [[1,2,3], [6,5,4]] >>> b = np.array(a) >>> print(a) [[1, 2, 3], [6, 5, 4]] >>> print(b) [[1 2 3] [6 5 4]] >>> type(b) >>> type(a) >>> c = np.array(a, float) >>> print(c) [[ 1. 2. 3.] [ 6. 5. 4.]] >>> import numpy as np a = [[1,2,3], [6,5,4]] print(f"lista a:\n{a}") print(f"type(a) = {type(a)}\n") b = np.array(a) print(f"array b:\n{b}") print(f"type(b) = {type(b)}") print(f"b.dtype = {b.dtype}\n") c = np.array(a, float) print(f"array c:\n{c}") print(f"type(c) = {type(c)}") print(f"c.dtype = {c.dtype}\n") 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. .. code:: python >>> 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``: .. code:: python >>> 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)``. Í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. .. code:: python >>> 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: .. code:: python >>> 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 "", line 1, in 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. .. code:: python >>> 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: .. raw:: html 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. .. Falar de dots?? import numpy as np lista = [[0, 1, 2, 3], [4, 5, 6, 7], [8, 9, 10, 11]] print(f"lista =\n{lista} \n") print(f"lista[1:-1] =\n{lista[1:-1]} \n") mat = np.array(lista) print(f"mat =\n{mat} \n") print(f"mat[1:-1] =\n{mat[1:-1]} \n") print(f"mat[:,2] =\n{mat[:,2]} \n") print(f"mat[:,2:] =\n{mat[:,2:]} \n") 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. .. code:: python >>> 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. .. raw:: html .. trinket .. code:: python import numpy as np ar = np.arange(30).reshape(5,6) # cria um array com valores de 0 a 29, no formato (5,6) print('ar:\n', ar) pedaco = ar[1:4, :-3] # tira um pedaço de ar print('pedaco:\n', pedaco) copia = pedaco.copy() # faz uma cópia do pedaço print('copia:\n', copia) copia[1,1] = -1 print('copia:\n', copia) # muda um elemento em copia print('pedaco:\n', pedaco) # a mudança não altera o pedaço pedaco[1,1] = -1 print('pedaco:\n', pedaco) # muda um elemento no pedaço print('ar:\n', ar) # a mudança altera o array ar pedaco[:,:] = 99 # é possível mudar todos os elementos de uma vez print('pedaco:\n', pedaco) print('ar:\n', ar) # e a mudança pode ser vista também no array ar 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()`. .. code:: python 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()`: .. code:: python 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 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. Para saber mais --------------- * `Tuplas e mutabilidade `__. * `Matrizes usando listas aninhadas `__. * `NumPy quickstart `__.