#4 - Random e Funções Universais (ufunc) no NumPy

Como gerar números aleatórios com numpy.random e o que são as ufuncs (funções universais), incluindo por que usá-las no lugar de loops puros em Python.

Imagem de capa

Fechando a série, dois assuntos que parecem sem relação à primeira vista, mas que aparecem lado a lado o tempo todo em código real de NumPy: geração de números aleatórios (muito usada pra simular dados, embaralhar amostras, inicializar experimentos) e as ufuncs, o mecanismo por trás de praticamente toda operação rápida que você já fez em um array até agora.

O módulo numpy.random

O NumPy tem seu próprio gerador de números pseudo-aleatórios, separado do módulo random da biblioteca padrão do Python — e é ele que você deve usar quando o resultado é um array, porque é bem mais eficiente que gerar valor por valor em um loop.

random.randint()

np.random.randint() gera um (ou vários) inteiros aleatórios dentro de um intervalo.

import numpy as np

numero = np.random.randint(100)
print(numero)

A saída é (o valor exato muda a cada execução):

73

Isso gera um inteiro entre 0 (incluso) e 100 (excluso). Com o parâmetro size, você gera um array inteiro de uma vez:

arr = np.random.randint(100, size=(5,))
print(arr)

matriz = np.random.randint(100, size=(3, 4))
print(matriz)

A saída é:

[45 12 87  3 66]
[[41 96  8 23]
 [59  2 74 31]
 [18 65 90  7]]

random.rand()

Enquanto randint() gera inteiros, np.random.rand() gera floats aleatórios entre 0 e 1.

numero = np.random.rand()
print(numero)

arr = np.random.rand(5)
print(arr)

A saída é:

0.372819...
[0.15 0.89 0.34 0.61 0.02]

Assim como randint(), dá pra passar as dimensões direto como argumentos pra gerar um array multidimensional:

matriz = np.random.rand(2, 3)
print(matriz)

A saída é:

[[0.12 0.75 0.44]
 [0.90 0.03 0.58]]

random.choice()

np.random.choice() sorteia valores a partir de um array que você fornece — em vez de um intervalo numérico genérico, você escolhe de que “conjunto” o sorteio sai.

opcoes = np.array([3, 5, 7, 9])
sorteio = np.random.choice(opcoes, size=10)
print(sorteio)

A saída é:

[7 3 9 5 7 7 3 9 5 7]

O mais interessante é o parâmetro p, que define a probabilidade de cada valor ser sorteado — as probabilidades precisam somar 1:

opcoes = np.array([3, 5, 7, 9])
probabilidades = [0.1, 0.3, 0.6, 0.0]

sorteio = np.random.choice(opcoes, size=20, p=probabilidades)
print(sorteio)

A saída é:

[7 7 5 3 7 5 7 7 3 5 7 7 5 7 7 3 5 7 7 5]

Com essas probabilidades, o valor 7 deve aparecer bem mais que os outros (60% de chance a cada sorteio), e o 9 nunca aparece (0% de chance). Repare que o p também pode receber uma tupla de size, gerando um array 2-D de sorteios de uma vez.

Nota: por padrão, o NumPy usa um gerador global que muda de resultado a cada execução do script. Se você precisa de resultados reproduzíveis (por exemplo, pra comparar experimentos), vale pesquisar sobre np.random.seed() — assunto que passa longe do escopo deste post, mas é bom saber que existe.

O que são ufuncs

ufunc é abreviação de “universal function” — são as funções do NumPy que operam elemento a elemento sobre um ndarray, de forma vetorizada. np.add(), np.sqrt(), np.sin(), e praticamente qualquer função do NumPy que você já usou em um array inteiro, são ufuncs.

Por que usar ufuncs em vez de loop puro

A resposta curta é: performance. Um loop for em Python percorre elemento por elemento na camada do interpretador, que é lenta. Uma ufunc empurra a operação pra dentro de código compilado (C), processando o array inteiro de uma vez — o que costuma ser ordens de magnitude mais rápido, especialmente em arrays grandes.

Exemplo 1 — somando dois arrays sem ufunc

x = [1, 2, 3, 4]
y = [4, 5, 6, 7]
z = []

for i, j in zip(x, y):
    z.append(i + j)

print(z)

A saída é:

[5, 7, 9, 11]

Funciona, mas é Python “puro” percorrendo listas — sem nenhuma vetorização por baixo.

Exemplo 2 — a mesma soma com np.add()

import numpy as np

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

z = np.add(x, y)
print(z)

A saída é:

[ 5  7  9 11]

Mesmo resultado, mas a soma inteira acontece dentro do NumPy, sem passar elemento por elemento pelo interpretador Python. Em um array de 4 elementos a diferença é irrelevante; em um array de alguns milhões, é o tipo de coisa que separa um script que roda em segundos de um que roda em minutos.

Verificando se algo é uma ufunc

Toda ufunc tem o tipo numpy.ufunc. Dá pra checar isso com type():

print(type(np.add))
print(type(np.concatenate))

A saída é:

<class 'numpy.ufunc'>
<class 'function'>

np.add é uma ufunc de verdade; np.concatenate, apesar de também ser uma função do NumPy, não segue esse mecanismo — é uma função Python comum por baixo.

Criando sua própria ufunc

Você também pode transformar uma função Python normal em uma ufunc, usando np.frompyfunc(). Isso não te dá o ganho de performance de uma ufunc nativa (ela continua rodando em Python por baixo), mas te dá o comportamento vetorizado — aplicar a função elemento a elemento automaticamente, sem escrever loop.

Exemplo 1

def triplica(x):
    return x * 3

minha_ufunc = np.frompyfunc(triplica, 1, 1)

resultado = minha_ufunc([1, 2, 3, 4])
print(resultado)
print(type(minha_ufunc))

A saída é:

[3 6 9 12]
<class 'numpy.ufunc'>

Os dois últimos argumentos de frompyfunc() são: quantos arrays de entrada a função espera, e quantos arrays de saída ela produz. No exemplo acima, triplica recebe 1 valor e retorna 1 valor, então é frompyfunc(triplica, 1, 1).

Exemplo 2 — uma ufunc com dois argumentos

def soma_customizada(x, y):
    return x + y

minha_soma = np.frompyfunc(soma_customizada, 2, 1)

resultado = minha_soma([1, 2, 3], [10, 20, 30])
print(resultado)

A saída é:

[11 22 33]

Operações aritméticas vetorizadas

Já vimos np.add() lá em cima, mas existe uma ufunc equivalente pra cada operador aritmético — e todas funcionam elemento a elemento entre dois arrays de mesmo tamanho.

Comparando operador direto com a ufunc

Na prática, pra aritmética simples, você quase nunca precisa chamar a ufunc pelo nome: os operadores +, -, *, / já são vetorizados quando aplicados diretamente em arrays do NumPy.

x = np.array([10, 20, 30, 40])
y = np.array([1, 2, 3, 4])

print(x + y)
print(np.add(x, y))

A saída é:

[11 22 33 44]
[11 22 33 44]

Os dois retornam exatamente o mesmo resultado — x + y chama np.add() por baixo dos panos.

Outras ufuncs aritméticas

x = np.array([10, 20, 30, 40])
y = np.array([3, 4, 5, 6])

print(np.subtract(x, y))
print(np.multiply(x, y))
print(np.divide(x, y))
print(np.power(x, y))
print(np.mod(x, y))

A saída é:

[ 7 16 25 34]
[ 30  80 150 240]
[3.33 5.   6.   6.67]
[     1000   160000 24300000 4096000000]
[1 0 0 4]

np.subtract, np.multiply e np.divide correspondem a -, * e /. np.power eleva cada elemento de x à potência do elemento correspondente em y. np.mod retorna o resto da divisão — equivalente ao operador %.

Nota: o motivo de existir uma ufunc nomeada pra cada operador (além do próprio operador) é que as funções aceitam parâmetros extras — por exemplo where, pra aplicar a operação só onde uma condição é verdadeira — algo que o operador + sozinho não permite.

Com isso fecho a série “NumPy com Python”: criação e indexação de arrays, copy/view, shape e iteração, join/split/search/sort, e agora random e ufuncs. É uma base sólida pra seguir pra bibliotecas construídas em cima do NumPy, como pandas e scikit-learn.

Fonte adaptada: Random Numbers in NumPy, Random Data Distribution, NumPy ufuncs, Create Your Own ufunc, Simple Arithmetic