Capítulo 34, Avançado
Pandas e NumPy
O NumPy está por baixo de cada coluna. Saber ir e voltar entre os dois, e onde as convenções diferem, evita os resultados "quase certos" que mais custam a ser achados.
De Pandas para NumPy e de volta
O to_numpy() extrai o array de uma Series ou de um DataFrame. Em uma tabela, o resultado tem um só tipo: o NumPy não aceita um tipo por coluna, então o Pandas escolhe o que comporta todos. Para voltar, o DataFrame recebe o array, e você devolve os rótulos que o array não carrega:
import numpy as np
import pandas as pd
pd.set_option("display.width", 170)
pd.set_option("display.max_columns", 20)
CANONICO = {"engenharia": "Engenharia", "marketing": "Marketing", "finanças": "Finanças",
"ciência de dados": "Ciência de Dados", "rh": "RH"}
df = pd.read_csv("dados/funcionarios_100.csv").drop_duplicates("id_funcionario", keep="last")
df["salario"] = pd.to_numeric(df["salario"].str.replace("R$ ", "", regex=False))
df["departamento"] = df["departamento"].str.strip().str.lower().map(CANONICO)
df = df.dropna(subset=["salario", "departamento"]).reset_index(drop=True)
X = df[["experiencia", "projetos_concluidos"]].to_numpy(dtype=float)
print(X.shape, X.dtype)
de_volta = pd.DataFrame(X, index=df.index, columns=["experiencia", "projetos_concluidos"])
print(bool(de_volta.equals(df[["experiencia", "projetos_concluidos"]].astype(float))))
(90, 2) float64
True
Ausentes ao atravessar a fronteira
Um tipo anulável (Int64) com ausentes não cabe em um array de inteiros do NumPy. O to_numpy() devolve um array de objetos, a não ser que você diga o tipo e o valor que representa o ausente:
idade = pd.Series([30, None, 28], dtype="Int64")
print(idade.to_numpy().dtype)
print(idade.to_numpy(dtype="float64", na_value=np.nan))
float64
[30. nan 28.]
Funções do NumPy mantêm os rótulos
Passar uma Series a uma função do NumPy devolve uma Series com o mesmo índice: os rótulos viajam. Já o np.where devolve um array simples, e você reenvelopa (ou usa o where do próprio Pandas):
salario = df["salario"]
log_salario = np.log(salario)
print(type(log_salario).__name__, bool(log_salario.index.equals(salario.index)))
faixa = np.where(salario > salario.median(), "acima", "abaixo")
print(type(faixa).__name__)
faixa = pd.Series(faixa, index=salario.index)
print(faixa.value_counts().to_dict())
Series True
ndarray
{'abaixo': 45, 'acima': 45}
A armadilha do desvio padrão
O std do Pandas divide por n - 1 (a variância amostral, ddof=1), e o np.std divide por n (ddof=0). Um z-score calculado de cada jeito dá números diferentes, e quem mistura os dois (um na análise, outro no modelo) tem um erro pequeno que ninguém acha:
valores = df["salario"]
print(round(float(valores.std()), 2), round(float(np.std(valores)), 2), round(float(np.std(valores.to_numpy())), 2))
print(round(float(np.std(valores.to_numpy(), ddof=1)), 2) == round(float(valores.std()), 2))
2433.44 2419.88 2419.88
True
Repare no terceiro caso: o np.std(valores), aplicado à Series, deu o mesmo valor do array (ddof=0, 2419,88) e não o do .std() da própria Series (2433,44). O NumPy repassa o ddof=0 ao método da Series, e o Pandas respeita o que recebe. Ou seja, o mesmo dado e duas formas de pedir o desvio padrão no Pandas dão resultados diferentes. A regra: escreva o ddof explicitamente, em qualquer das duas bibliotecas, e confira que a análise e o modelo usam o mesmo.
Padronizar com broadcasting
Com o NumPy, padronizar todas as colunas de uma vez é uma linha (o broadcasting do capítulo 13 do curso de NumPy), e dá o mesmo resultado do Pandas quando o ddof coincide:
Z = (X - X.mean(axis=0)) / X.std(axis=0, ddof=1)
z_pandas = (df[["experiencia", "projetos_concluidos"]] - df[["experiencia", "projetos_concluidos"]].mean()) / \
df[["experiencia", "projetos_concluidos"]].std()
print(bool(np.allclose(Z, z_pandas.to_numpy())))
True
Uma regressão com o NumPy, conferida com o Pandas
O salário cresce com a experiência? A reta de mínimos quadrados responde, e a inclinação tem uma relação exata com a correlação do Pandas (inclinação = correlação × desvio_y / desvio_x):
exp = df["experiencia"].to_numpy(dtype=float)
sal = df["salario"].to_numpy(dtype=float)
A = np.column_stack([np.ones(len(exp)), exp])
(intercepto, inclinacao), *_ = np.linalg.lstsq(A, sal, rcond=None)
print(round(float(inclinacao)), round(float(intercepto)))
pela_correlacao = df["salario"].corr(df["experiencia"]) * df["salario"].std() / df["experiencia"].std()
print(bool(np.isclose(inclinacao, pela_correlacao)))
379 5044
True
Cada ano de experiência soma cerca de 380 reais ao salário neste conjunto (que foi gerado assim, mais ruído). A segunda linha mostra que o NumPy e o Pandas contam a mesma história por caminhos diferentes.
Variáveis de texto como números
O get_dummies transforma uma coluna de categorias em colunas de 0 e 1, a matriz que um modelo espera:
dummies = pd.get_dummies(df["departamento"], dtype=float)
print(dummies.shape, dummies.sum(axis=1).unique().tolist(), dummies.columns.tolist())
print(type(dummies.to_numpy()).__name__)
(90, 5) [1.0] ['Ciência de Dados', 'Engenharia', 'Finanças', 'Marketing', 'RH']
ndarray
Cada linha soma 1, porque cada pessoa está em um departamento.
Exercício 1
Padronizar e devolver com os rótulos
Escreva padronizar(tabela, colunas), que devolva uma DataFrame com as colunas padronizadas pelo NumPy (com ddof=1), mantendo o índice e os nomes, e confira que o resultado é igual ao z-score do Pandas.