Até agora, não pensamos em fazer nosso código DataFrames.jl
rápido. Como tudo em Julia, DataFrames.jl
pode ser bem veloz. Nesta seção, daremos algumas dicas e truques de desempenho.
Como explicamos em Section 3.2.1.1, funções que terminam com uma exclamação !
são um padrão comum para denotar funções que modificam um ou mais de seus argumentos. O contexto do código de alta performance em Julia, significa que **funções com !
apenas mudarão no local os objetos que fornecemos como argumentos.
Quase todas as funções DataFrames.jl
que vimos tem uma "!
gêmea". Por exemplo, filter
tem um in-loco filter!
, select
tem select!
, subset
tem subset!
, e assim por diante. Observe que essas funções não retornam um novo DataFrame
, mas, ao invés vez disso, elas atualizam o DataFrame
sobre o qual atuam. Além disso, DataFrames.jl
(versão 1.3 em diante) suporta in-loco leftjoin
com a função leftjoin!
. Essa função atualiza o DataFrame
esquerdo com as colunas unidas do DataFrame
direito. Há uma ressalva de que cada linha da tabela esquerda deve corresponder a no máximo uma linha da tabela direita.
Se você deseja a mais alta velocidade e desempenho em seu código, definitivamente deve usar as funções !
ao invés das funções regulares de DataFrames.jl
.
Vamos voltar para o exemplo da função select
no começo de Section 4.4. Aqui está o DataFrame
responses:
responses()
id | q1 | q2 | q3 | q4 | q5 |
---|---|---|---|---|---|
1 | 28 | us | F | B | A |
2 | 61 | fr | B | C | E |
Agora vamos desempenhar a seleção com a função select
, como fizemos antes:
select(responses(), :id, :q1)
id | q1 |
---|---|
1 | 28 |
2 | 61 |
Aqui está a função in-loco:
select!(responses(), :id, :q1)
id | q1 |
---|---|
1 | 28 |
2 | 61 |
O macro @allocated
nos diz quanta memória foi alocada. Em outras palavras, quanta informação nova o computador teve que armazenar em sua memória enquanto executava o código. Vamos ver qual será o desempenho:
df = responses()
@allocated select(df, :id, :q1)
7296
df = responses()
@allocated select!(df, :id, :q1)
7088
Como pudemos ver, select!
aloca menos que select
. Portanto, é mais rápido e consome menos memória.
Existem duas formas de acessar a coluna DataFrame. Elas diferem na forma como são acessadas: uma cria uma “visualização” para a coluna sem copiar e a outra cria uma coluna totalmente nova copiando a coluna original.
A primeira usa o operador dot regular .
seguido pelo nome da coluna, como em df.col
. Essa forma de acesso não copia a coluna col
. Ao invés disso df.col
cria uma “visualização” que é um link para a coluna original sem realizar nenhuma alocação. Além do mais, a sintaxe df.col
é a mesma que df[!, :col]
com a exclamação !
como a seletora de linha.
A segunda forma de acessar uma coluna DataFrame
é a df[:, :col]
com os dois pontos :
como o seletor de linha. Esse tipo de acesso copia a coluna col
, portanto, tenha cuidado, pois isso pode produzir alocações indesejadas.
Como antes, vamos experimentar essas duas maneiras de acessar uma coluna no DataFrame
responses:
df = responses()
@allocated col = df[:, :id]
517788
df = responses()
@allocated col = df[!, :id]
0
Quando acessamos uma coluna sem copiá-la estamos fazendo alocações zero e nosso código deve ser mais rápido. Então, se você não precisa de uma cópia, sempre acesse suas colunas DataFrame
s com df.col
ou df[!, :col]
ao invés de df[:, :col]
.
Se você der uma olhada no output ajuda para CSV.read
, você verá que existe uma função de conveniência idêntica à função chamada CSV.File
com os mesmos argumentos de palavras-chave. Ambos CSV.read
e CSV.File
vão ler o conteúdo de um arquivo CSV, mas eles se diferem no comportamento padrão. CSV.read
, por padrão, não fará cópias dos dados de entrada. Ao invés disso, CSV.read
irá passar todos os dados para o segundo argumento (conhecido como “sink”).
Então, algo assim:
df = CSV.read("file.csv", DataFrame)
passará todos os dados recebidos de file.csv
para o sink DataFrame
, retornando assim um tipo DataFrame
que vamos armazenar na variável df
.
Para o caso do CSV.File
, o comportamento padrão é o oposto: ele fará cópias de todas as colunas contidas no arquivo CSV. Além disso, a sintaxe é um pouco diferente. Precisamos embrulhar tudo que o CSV.File
retorna em uma função construtora DataFrame
:
df = DataFrame(CSV.File("file.csv"))
Ou, com o operador pipe |>
:
df = CSV.File("file.csv") |> DataFrame
Como dissemos, CSV.File
fará cópias de cada coluna no arquivo CSV subjacente. Em última análise, se você quiser o máximo de desempenho, você definitivamente usaria CSV.read
em vez de CSV.File
. É por isso que cobrimos apenas CSV.read
em Section 4.1.1.
Agora vamos voltar nossa atenção para o CSV.jl
. Especificamente, o caso em que temos vários arquivos CSV para ler em um único DataFrame
. Desde a versão 0.9 do CSV.jl
podemos fornecer um vetor de strings representando nomes de arquivos. Antes, precisávamos realizar algum tipo de leitura de vários arquivos e, em seguida, concatenar verticalmente os resultados em um único DataFrame
. Para exemplificar, o código abaixo lê vários arquivos CSV e os concatena verticalmente usando vcat
em um único DataFrame
com a função reduce
:
files = filter(endswith(".csv"), readdir())
df = reduce(vcat, CSV.read(file, DataFrame) for file in files)
Uma característica adicional é que reduce
não será paralelizado porque precisa manter a ordem de vcat
que segue a mesma ordem do vetor files
.
Com esta funcionalidade em CSV.jl
nós simplesmente passamos o vetor files
para a função CSV.read
:
files = filter(endswith(".csv"), readdir())
df = CSV.read(files, DataFrame)
CSV.jl
designará um arquivo para cada thread disponível no computador enquanto ele concatena lentamente cada saída analisada por thread em um DataFrame
. Portanto, temos o benefício adicional do multithreading que não temos com a opção reduce
.
Se você estiver lidando com dados com muitos valores categóricos, ou seja, muitas colunas com dados textuais que representam dados qualitativos de alguma forma diferentes, você provavelmente se beneficiaria usando a compressão CategoricalArrays.jl
.
Por padrão, CategoricalArrays.jl
usará um inteiro sem sinal no tamanho de 32 bits UInt32
para representar as categorias subjacentes:
typeof(categorical(["A", "B", "C"]))
CategoricalVector{String, UInt32, String, CategoricalValue{String, UInt32}, Union{}}
Isso significa que CategoricalArrays.jl
pode representar até \(2^{32}\) categorias diferentes em um determinado vetor ou coluna, o que é um valor enorme (perto de 4,3 bilhões). Você provavelmente nunca precisaria ter esse tipo de capacidade para lidar com dados regulares17. É por isso que categorical
tem um argumento compress
que aceita true
ou false
para determinar se os dados categóricos subjacentes são compactados ou não. Se você passar compress=true
, CategoricalArrays.jl
tentará compactar os dados categóricos subjacentes para a menor representação possível em UInt
. Por exemplo, o vetor categorical
anterior seria representado como um inteiro sem sinal de tamanho 8 bits UInt8
(principalmente porque este é o menor inteiro sem sinal disponível em Julia):
typeof(categorical(["A", "B", "C"]; compress=true))
CategoricalVector{String, UInt8, String, CategoricalValue{String, UInt8}, Union{}}
O que tudo isso significa? Suponha que você tenha um grande vetor. Por exemplo, considere um vetor com um milhão de entradas, mas apenas 4 categorias subjacentes: A, B, C ou D. Se você não compactar o vetor categórico resultante, você terá um milhão de entradas armazenadas como UInt32
. Por outro lado, se você compactar, você terá um milhão de entradas armazenadas como UInt8
. Usando a função Base.summarysize
podemos obter o tamanho subjacente, em bytes, de um determinado objeto. Então, vamos quantificar quanta memória mais precisaríamos ter se não comprimissemos nosso um milhão de vetores categóricos:
using Random
one_mi_vec = rand(["A", "B", "C", "D"], 1_000_000)
Base.summarysize(categorical(one_mi_vec))
4000612
4 milhões de bytes, que é aproximadamente 3,8 MB. Não nos entenda mal, esta é uma boa melhoria em relação ao tamanho da string bruta:
Base.summarysize(one_mi_vec)
8000076
Reduzimos 50% do tamanho dos dados brutos usando uma representação subjacente padrão CategoricalArrays.jl
como UInt32
.
Agora vamos ver como nos sairíamos com a compressão:
Base.summarysize(categorical(one_mi_vec; compress=true))
1000564
Reduzimos o tamanho para 25% (um quarto) do tamanho original do vetor não compactado sem perder informações. Nosso vetor categórico compactado agora tem 1 milhão de bytes, que é aproximadamente 1,0 MB.
Portanto, sempre que possível, no interesse do desempenho, considere usar compress=true
em seus dados categóricos.
17. observe também que dados regulares (até 10.000 linhas) não são big data (mais de 100.000 linhas). Portanto, se você estiver lidando principalmente com big data, tenha cuidado ao limitar seus valores categóricos.↩︎