Introdução aos data.frames

Programação
Referência guiada
data.frames
Uma referência guiada para inspeccionar, seleccionar e verificar tabelas.

0 Introdução aos data.frames

Um data.frame é uma tabela em que cada coluna representa uma variável e cada linha contém um registo. Se vem da estatística, pode pensar numa folha de cálculo com regras mais explícitas. Mas o que representa esse registo? Pode ser uma pessoa, uma sessão, uma tentativa ou outra unidade. A forma rectangular da tabela não nos dá essa informação. Quando chegarmos a medidas repetidas, essa distinção vai deixar de ser apenas uma questão de organização.

Nesta página vamos fazer quatro coisas, uma de cada vez: inspeccionar, seleccionar, modificar e verificar. Começamos com uma tabela pequena para que seja possível prever o resultado de cada operação. A gramática do R explica os objectos, vectores e índices que usamos aqui. A importação de dados mostra depois como estas tabelas chegam ao R a partir de ficheiros, e a limpeza de dados acrescenta decisões menos arrumadas e mais parecidas com o que acontece num estudo real.

Preparar os Pacotes

Vamos usar dplyr para seleccionar linhas e colunas e transformar tabelas. Se necessário, instale-o uma vez na consola, fora do script, como explicado na referência de pacotes. No início do script, carregamos o pacote:

Criar uma Tabela

Neste exemplo, cada vector fornecido a data.frame() tem um elemento por linha. O R consegue reciclar alguns vectores mais curtos, mas não queremos confiar nisso ao construir uma tabela: cada coluna deve descrever os mesmos registos.

ds <- data.frame(
    id = 1:5,
    age = c(21, 32, 24, 46, 33),
    gender = c("M", "F", "M", "F", "F"),
    rts_ms = c(1435, 4352, 2232, 2820, 4552)
)

Tibbles

Um tibble é uma forma de data.frame usada nos pacotes do tidyverse. Podemos converter a tabela que já criámos com as_tibble() ou construir uma directamente com tibble(), também disponíveis ao carregar dplyr:

ds_tbl <- as_tibble(ds)
class(ds_tbl)
[1] "tbl_df"     "tbl"        "data.frame"
ds_tbl
id age gender rts_ms
1 21 M 1435
2 32 F 4352
3 24 M 2232
4 46 F 2820
5 33 F 4552
small_tbl <- tibble(id = 1:2, age = c(21, 32))

As colunas continuam a descrever os mesmos registos. Na consola, a impressão dum tibble limita as linhas e colunas apresentadas e indica os tipos das colunas. Outra diferença útil: ds_tbl[, "age"] conserva uma tabela, enquanto ds[, "age"] devolve um vector. Para obter o vector em ambos, podemos usar $. As funções de dplyr que vamos usar aceitam ambas as formas de tabela; conservamos ds como data.frame para reconhecer também o comportamento da indexação de base R.

1 Inspeccionar Antes de Alterar

Antes de mexermos na tabela, convém perceber o que ela contém: tamanho, nomes, primeiras e últimas linhas, estrutura e resumos. Não é a parte mais excitante da análise, mas costuma ser muito mais rápida do que descobrir meia hora depois que estivemos a trabalhar na coluna errada. Podemos explorar estes comandos no interpretador, incluindo View() para abrir a tabela. No script, guardamos as transformações que vamos usar na análise e os comentários que explicam as nossas escolhas. Também podemos conservar inspecções que seja útil repetir, mas View() fica para a sessão interactiva. Comecemos pelas dimensões: dim() devolve linhas e colunas nessa ordem, e nrow() e ncol() devolvem cada número separadamente.

dim(ds)
[1] 5 4
nrow(ds)
[1] 5
ncol(ds)
[1] 4

dim() devolve 5 4: cinco linhas e quatro colunas. Os dois comandos seguintes pedem cada dimensão separadamente. Agora confirmemos os nomes das colunas. names() e colnames() dão o mesmo resultado num data.frame. Basta escolher uma delas no código que escrevermos.

names(ds)
[1] "id"     "age"    "gender" "rts_ms"
[1] "id"     "age"    "gender" "rts_ms"

Para uma primeira leitura dos registos, head() mostra o início e tail() o fim da tabela. Aqui pedimos apenas as duas últimas linhas a tail().

head(ds)
id age gender rts_ms
1 21 M 1435
2 32 F 4352
3 24 M 2232
4 46 F 2820
5 33 F 4552
tail(ds, n = 2)
id age gender rts_ms
4 4 46 F 2820
5 5 33 F 4552

Por fim, str() descreve a estrutura e summary() calcula resumos simples. São inspecções diferentes: uma ajuda a confirmar os tipos dos objectos e a outra ajuda a detectar valores ou intervalos inesperados.

str(ds)
'data.frame':   5 obs. of  4 variables:
 $ id    : int  1 2 3 4 5
 $ age   : num  21 32 24 46 33
 $ gender: chr  "M" "F" "M" "F" ...
 $ rts_ms: num  1435 4352 2232 2820 4552
       id         age             gender      rts_ms    
 Min.   :1   Min.   :21.0   Length   :5   Min.   :1435  
 1st Qu.:2   1st Qu.:24.0   N.unique :2   1st Qu.:2232  
 Median :3   Median :32.0   N.blank  :0   Median :2820  
 Mean   :3   Mean   :31.2   Min.nchar:1   Mean   :3078  
 3rd Qu.:4   3rd Qu.:33.0   Max.nchar:1   3rd Qu.:4352  
 Max.   :5   Max.   :46.0                 Max.   :4552  
View(ds)

2 Seleccionar Linhas e Colunas

filter() e select()

filter() escolhe linhas por condições sobre as colunas; select() escolhe colunas pelos nomes. Podemos escrever age e rts_ms directamente, sem repetir ds$. Comecemos pelas pessoas adultas:

adults <- filter(ds, age >= 18)
adults
id age gender rts_ms
1 21 M 1435
2 32 F 4352
3 24 M 2232
4 46 F 2820
5 33 F 4552

Neste exemplo, todas as pessoas têm pelo menos 18 anos. Para conservar apenas adultos com tempos abaixo de 4000 ms, acrescentamos outra condição. Condições separadas por vírgulas têm de ser todas satisfeitas:

selected <- ds |>
    filter(age >= 18, rts_ms < 4000)
selected
id age gender rts_ms
1 21 M 1435
3 24 M 2232
4 46 F 2820

Ficamos com três pessoas. & também exige ambas as condições; | permitiria satisfazer uma ou outra. filter() mantém só condições com resultado TRUE: uma condição com resultado NA deixa a linha de fora. Convém decidir o que fazer aos valores em falta, em vez de os tratar como exclusões automáticas.

Para escolher colunas, usamos select(). O pipe nativo |> passa o resultado de cada operação para o primeiro argumento da seguinte. Leia o segundo exemplo como «partir de ds, filtrar as idades e escolher id e rts_ms»:

select(ds, id, age)
id age
1 21
2 32
3 24
4 46
5 33
selected <- ds |>
    filter(age < 40) |>
    select(id, rts_ms)
selected
id rts_ms
1 1435
2 4352
3 2232
5 4552

Esta será a nossa forma habitual de seleccionar dados nos próximos tutoriais. Vale a pena reconhecer também a indexação de base R, usada para pedir posições concretas e corrigir células.

Reconhecer a Indexação de Base R

A notação [rows, columns] permite escolher as duas dimensões. À esquerda da vírgula seleccionamos linhas e à direita seleccionamos colunas. Quando um dos lados fica vazio, pedimos todas as linhas ou todas as colunas. Comecemos pelas linhas, sem acrescentar já outras condições:

ds[1, ]
id age gender rts_ms
1 21 M 1435
ds[2:4, ]
id age gender rts_ms
2 2 32 F 4352
3 3 24 M 2232
4 4 46 F 2820
ds[-2, ]
id age gender rts_ms
1 1 21 M 1435
3 3 24 M 2232
4 4 46 F 2820
5 5 33 F 4552

O primeiro resultado tem uma linha, o segundo tem três e o terceiro exclui a segunda. Repare que estamos a seleccionar linhas sem ainda decidir que colunas queremos conservar. Para escolher uma coluna, podemos usar a posição ou, de preferência, o nome. O operador $ é uma forma curta de pedir uma coluna pelo nome e devolve o vector que está nessa coluna. As duas formas seguintes pedem a mesma coluna. Compare o resultado e note que é um vector.

ds[ , "age"]
[1] 21 32 24 46 33
ds$age
[1] 21 32 24 46 33

Quando queremos mais do que uma coluna, colocamos os nomes num vector. Também podemos usar posições ou o sinal - para excluir uma coluna, mas os nomes costumam tornar a intenção mais fácil de ler.

ds[ , c("id", "age")]
id age
1 21
2 32
3 24
4 46
5 33
ds[ , 2:3]
age gender
21 M
32 F
24 M
46 F
33 F
ds[ , -4]
id age gender
1 21 M
2 32 F
3 24 M
4 46 F
5 33 F

Podemos agora combinar as duas dimensões. O primeiro exemplo pede uma célula. O segundo pede as colunas id e age das três primeiras linhas.

ds[1, "age"]
[1] 21
ds[1:3, c("id", "age")]
id age
1 21
2 32
3 24

Uma condição lógica no lugar das linhas permite seleccionar registos. Leia o código abaixo como «todas as linhas cuja idade é pelo menos 18». Guardamos o resultado num novo objecto para podermos inspeccioná-lo sem perder ds.

adults <- ds[ds$age >= 18, ]
adults
id age gender rts_ms
1 21 M 1435
2 32 F 4352
3 24 M 2232
4 46 F 2820
5 33 F 4552

Neste exemplo, as cinco pessoas satisfazem o critério. O resultado continua a ser uma tabela, porque deixámos a parte das colunas vazia. Podemos acrescentar uma segunda condição com &. Aqui seleccionamos adultos com tempos abaixo de 4000 ms.

selected <- ds[ds$age >= 18 & ds$rts_ms < 4000, ]
selected
id age gender rts_ms
1 1 21 M 1435
3 3 24 M 2232
4 4 46 F 2820

Condições com subset()

Para seleccionar linhas por condições sobre as variáveis da tabela, subset() é mais cómodo: podemos escrever age e rts_ms directamente, sem repetir ds$ em cada condição. O primeiro argumento é a tabela e o segundo é a expressão lógica que cada linha tem de satisfazer.

selected_subset <- subset(ds, age >= 18 & rts_ms < 4000)
selected_subset
id age gender rts_ms
1 1 21 M 1435
3 3 24 M 2232
4 4 46 F 2820

Obtemos as mesmas três pessoas do filtro com parênteses rectos. & exige ambas as condições; | permitiria satisfazer uma ou outra. O argumento select permite ainda escolher colunas, conservando uma tabela:

subset(ds, age < 40, select = c(id, rts_ms))
id rts_ms
1 1 1435
2 2 4352
3 3 2232
5 5 4552

subset() mantém só as linhas cuja condição é TRUE. Se a condição der NA, essa linha fica de fora. Convém decidir se isso corresponde ao critério do estudo, em vez de tratar valores em falta como exclusões automáticas.

Não precisamos de conservar várias versões do mesmo filtro. subset() aparece em código de base R; nos workflows de limpeza, continuaremos com filter() e select().

Uma Coluna como Vector

Para pedir apenas o vector rts_ms, usamos aqui só a condição da idade. Este vector inclui também os tempos de 4000 ms ou mais, que o filtro anterior retirou.

adult_rts <- ds[ds$age >= 18, "rts_ms"]
adult_rts
[1] 1435 4352 2232 2820 4552

Por defeito, seleccionar uma só coluna pode simplificar o resultado para um vector. drop = FALSE conserva um data.frame, o que faz diferença quando o resultado vai ser passado a uma função que espera uma tabela.

ages_table <- ds[ , "age", drop = FALSE]
adults_table <- ds[ds$age >= 18, , drop = FALSE]

Há ainda um cuidado importante quando a condição pode conter NA: a selecção com [ pode criar uma linha de NA. Nesse caso, escreva explicitamente o que deve acontecer aos valores em falta, por exemplo com !is.na().

known_ages <- ds[
    !is.na(ds$age) & ds$age >= 18,
    ,
    drop = FALSE
]

3 Modificar e Acrescentar

Podemos atribuir valores a uma coluna inteira, a uma célula ou apenas a algumas linhas. Enquanto estamos a aprender, é muitas vezes boa ideia trabalhar numa cópia. Assim conseguimos comparar o antes e o depois e voltar atrás sem reconstruir o objecto original.

prepared <- ds
prepared$rts_s <- prepared$rts_ms / 1000
prepared$age_centered <- prepared$age - mean(prepared$age)

A cópia tem agora duas colunas novas. Inspeccionemos as colunas antes de alterar valores individuais. Mantemos a vírgula explícita: o lado das linhas fica vazio e os nomes à direita escolhem as colunas. Também poderá encontrar prepared[c(...)], sem vírgula, que num data.frame selecciona colunas; aqui preferimos mostrar as duas dimensões.

prepared[, c("rts_ms", "rts_s", "age_centered")]
rts_ms rts_s age_centered
1435 1.435 -10.2
4352 4.352 0.8
2232 2.232 -7.2
2820 2.820 14.8
4552 4.552 1.8

Podemos também corrigir uma célula, conservando um registo da razão. Suponhamos que a consulta do registo original confirmou 1450 ms para a primeira pessoa. Corrigimos os milissegundos na cópia e voltamos a calcular os segundos, para as duas colunas concordarem. ds conserva o valor recebido, 1435 ms; prepared passa a conter a versão corrigida.

# Hypothetical correction confirmed against the source record: 1450 ms.
prepared[1, "rts_ms"] <- 1450
prepared$rts_s <- prepared$rts_ms / 1000
prepared[1, c("rts_ms", "rts_s")]
rts_ms rts_s
1450 1.45

Também podemos criar uma categoria para todas as linhas e depois substituir o valor apenas nas linhas que satisfazem uma condição.

# Set a value for every row before modifying selected rows.
prepared$age_group <- "30_or_older"
prepared[prepared$age < 30, "age_group"] <- "under_30"
prepared[, c("age", "age_group")]
age age_group
21 under_30
32 30_or_older
24 under_30
46 30_or_older
33 30_or_older

Para acrescentar uma linha, rbind() precisa de colunas correspondentes. Para acrescentar uma coluna, cbind() junta por posição. Esta operação associa as linhas pela ordem em que aparecem, sem procurar uma chave relacional.

extra <- rbind(
    ds,
    data.frame(id = 6, age = 29, gender = "M", rts_ms = 3100)
)
ordered <- cbind(ds, order = seq_len(nrow(ds)))

4 Apagar e Renomear

Atribuir NULL apaga uma coluna. Também podemos criar uma nova tabela sem essa coluna e deixar o objecto original intacto. select(ds, -gender) exclui a coluna pelo nome. Para renomear, usamos rename(), com new_name = old_name. Em código de base R, também podemos alterar os nomes através de names():

without_gender <- select(ds, -gender)
renamed <- rename(ds, response_time_ms = rts_ms)
copy <- ds
copy$gender <- NULL
names(copy)[names(copy) == "rts_ms"] <- "response_time_ms"

Os nomes das linhas (row names) podem aparecer em tabelas importadas, mas não são uma boa escolha como identificador principal. Prefira uma coluna explícita como id: conseguimos inspeccioná-la, combiná-la e exportá-la sem depender de um atributo escondido.

5 Verificar Depois de Cada Passo

Código que corre sem erro pode fazer exactamente a coisa errada. Depois duma alteração, verifique aquilo que deveria ter mudado e aquilo que deveria ter ficado igual: dimensões, nomes, classes, intervalos, valores em falta ou chaves, conforme o caso.

nrow(extra)
[1] 6
ncol(prepared)
[1] 7
names(prepared)
[1] "id"           "age"          "gender"       "rts_ms"       "rts_s"       
[6] "age_centered" "age_group"   

Estas verificações rápidas confirmam o tamanho e os nomes. Para perceber se os valores também fazem sentido, inspeccionemos a estrutura, o resumo da nova coluna e algumas linhas.

str(prepared)
'data.frame':   5 obs. of  7 variables:
 $ id          : int  1 2 3 4 5
 $ age         : num  21 32 24 46 33
 $ gender      : chr  "M" "F" "M" "F" ...
 $ rts_ms      : num  1450 4352 2232 2820 4552
 $ rts_s       : num  1.45 4.35 2.23 2.82 4.55
 $ age_centered: num  -10.2 0.8 -7.2 14.8 1.8
 $ age_group   : chr  "under_30" "30_or_older" "under_30" "30_or_older" ...
summary(prepared$rts_s)
   Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
  1.450   2.232   2.820   3.081   4.352   4.552 
head(prepared)
id age gender rts_ms rts_s age_centered age_group
1 21 M 1450 1.450 -10.2 under_30
2 32 F 4352 4.352 0.8 30_or_older
3 24 M 2232 2.232 -7.2 under_30
4 46 F 2820 2.820 14.8 30_or_older
5 33 F 4552 4.552 1.8 30_or_older

Vejamos também quantos registos havia antes e depois de acrescentar a linha, os tempos convertidos e quantas vezes aparece cada identificador:

nrow(ds)
[1] 5
nrow(extra)
[1] 6
prepared[, c("id", "rts_ms", "rts_s")]
id rts_ms rts_s
1 1450 1.450
2 4352 4.352
3 2232 2.232
4 2820 2.820
5 4552 4.552
table(ds$id, useNA = "ifany")

1 2 3 4 5 
1 1 1 1 1 

Devemos encontrar cinco registos antes e seis depois. Os tempos em segundos devem ser positivos e corresponder aos milissegundos divididos por 1000; a primeira linha corrigida tem 1450 ms e 1.45 s. Na tabela de frequências, cada id de 1 a 5 aparece uma vez. useNA = "ifany" mostraria também uma contagem de identificadores em falta, se os houvesse. Se algum resultado for inesperado, investigamos os registos antes de continuar. Estes resultados ajudam a detectar problemas, mas não nos dizem se uma correcção ou exclusão tem justificação científica.

6 Combinar Tabelas: Posição Não É Chave

rbind() empilha registos com a mesma estrutura. cbind() junta colunas pela posição e pode produzir uma tabela perfeitamente plausível mesmo quando as linhas deixaram de corresponder. Para combinar por identificador usamos merge() ou, mais tarde, joins de dplyr.

measures <- data.frame(id = 1:5, group = c("A", "A", "B", "B", "B"))
table(ds$id, useNA = "ifany")

1 2 3 4 5 
1 1 1 1 1 
table(measures$id, useNA = "ifany")

1 2 3 4 5 
1 1 1 1 1 

Em ambas as tabelas, os identificadores de 1 a 5 aparecem uma vez cada, sem valores em falta. É a correspondência um-para-um que esperamos neste exemplo. Agora combinamos pelas colunas id. merge() procura os valores correspondentes, em vez de confiar na ordem das linhas.

combined <- merge(ds, measures, by = "id")
combined
id age gender rts_ms group
1 21 M 1435 A
2 32 F 4352 A
3 24 M 2232 B
4 46 F 2820 B
5 33 F 4552 B
nrow(ds)
[1] 5
nrow(combined)
[1] 5

Continuamos com cinco linhas. Na tabela combinada, as pessoas 1 e 2 pertencem ao grupo A e as restantes ao grupo B, como em measures.

A chave deve representar a relação que queremos. Se houver chaves duplicadas, uma combinação pode gerar várias linhas para o mesmo registo e aumentar a tabela. Não trate esse aumento como um problema de apresentação. Pare e perceba primeiro se a relação devia ser um-para-um, um-para-muitos ou muitos-para-muitos.

7 Exercícios

  1. Inspeccione ds com dim(), str() e summary(). Escreva o que cada função acrescenta e o que representa uma linha desta tabela.
Solução 1
dim(ds)        # Number of rows and columns.
[1] 5 4
str(ds)        # Classes and structure of each column.
'data.frame':   5 obs. of  4 variables:
 $ id    : int  1 2 3 4 5
 $ age   : num  21 32 24 46 33
 $ gender: chr  "M" "F" "M" "F" ...
 $ rts_ms: num  1435 4352 2232 2820 4552
summary(ds)    # Compact summaries, including ranges and missing values.
       id         age             gender      rts_ms    
 Min.   :1   Min.   :21.0   Length   :5   Min.   :1435  
 1st Qu.:2   1st Qu.:24.0   N.unique :2   1st Qu.:2232  
 Median :3   Median :32.0   N.blank  :0   Median :2820  
 Mean   :3   Mean   :31.2   Min.nchar:1   Mean   :3078  
 3rd Qu.:4   3rd Qu.:33.0   Max.nchar:1   3rd Qu.:4352  
 Max.   :5   Max.   :46.0                 Max.   :4552  
Cada linha representa uma pessoa nesta tabela. Noutra tabela, a unidade poderia ser diferente.
  1. Use filter() e select() para seleccionar id e rts_ms para idades inferiores a 40. Verifique dimensões e primeiras linhas. Explique o papel de cada função. Depois compare o acesso à coluna rts_ms de ds com $ e com [, "rts_ms"].
Solução 2
selected <- ds |>
    filter(age < 40) |>
    select(id, rts_ms)
dim(selected)
[1] 4 2
head(selected)
id rts_ms
1 1435
2 4352
3 2232
5 4552
rts_dollar <- ds$rts_ms
rts_brackets <- ds[, "rts_ms"]
filter() escolhe as linhas e select() conserva as duas colunas, numa tabela. $ e [, "rts_ms"] devolvem aqui o vector da coluna, porque ds é um data.frame. Num tibble, a selecção com [ conservaria uma tabela.
  1. Crie checked a partir de ds, acrescente rts_s, altere uma célula documentada e escreva duas verificações.
Solução 3
checked <- ds
checked$rts_s <- checked$rts_ms / 1000
# Hypothetical source-confirmed correction; keep both units consistent.
checked[1, "rts_ms"] <- 1450
checked$rts_s <- checked$rts_ms / 1000

nrow(ds)
[1] 5
nrow(checked)
[1] 5
checked[, c("id", "rts_ms", "rts_s")]
id rts_ms rts_s
1 1450 1.450
2 4352 4.352
3 2232 2.232
4 2820 2.820
5 4552 4.552
As duas tabelas têm cinco registos. A inspecção dos tempos mostra valores positivos, com os segundos a corresponder aos milissegundos divididos por 1000. Na primeira linha devemos encontrar 1450 ms e 1.45 s.
  1. Apague gender com NULL numa cópia e, separadamente, por selecção. Compare names() nos três objectos.
Solução 4
without_gender_null <- ds
without_gender_null$gender <- NULL

without_gender_selection <- select(ds, -gender)

names(ds)
[1] "id"     "age"    "gender" "rts_ms"
names(without_gender_null)
[1] "id"     "age"    "rts_ms"
names(without_gender_selection)
[1] "id"     "age"    "rts_ms"
ds conserva gender. As duas cópias ficam sem essa coluna. Nenhuma operação altera o objecto original.
  1. Renomeie rts_ms para response_time_ms sem alterar ds. Que objecto tem a coluna antiga?
Solução 5
renamed <- rename(ds, response_time_ms = rts_ms)
names(ds)
[1] "id"     "age"    "gender" "rts_ms"
names(renamed)
[1] "id"               "age"              "gender"           "response_time_ms"
A coluna antiga continua em ds. Em renamed, chama-se response_time_ms.
  1. Construa uma segunda tabela com id e combine-a com merge(). Teste o efeito de introduzir um id duplicado e explique o aumento (ou não) de linhas.
Solução 6
measures <- data.frame(
    id = 1:5,
    group = c("A", "A", "B", "B", "B")
)
combined <- merge(ds, measures, by = "id")
nrow(combined)
[1] 5
measures_dup <- rbind(measures, data.frame(id = 1, group = "C"))
combined_dup <- merge(ds, measures_dup, by = "id")
nrow(combined_dup)
[1] 6
A combinação normal tem cinco linhas. Com duas correspondências para id == 1, essa linha de ds combina com ambas e o resultado passa a ter seis linhas.
  1. Explique por que cbind() pode produzir uma tabela plausível mas errada quando as ordens de id diferem.
Solução 7
reordered_measures <- measures[c(3, 2, 1, 4, 5), ]
by_position <- cbind(ds, reordered_measures[, "group", drop = FALSE])
by_position[, c("id", "group")]
id group
3 1 B
2 2 A
1 3 A
4 4 B
5 5 B
cbind() junta a primeira linha com a primeira linha, sem procurar o mesmo id. Aqui, a pessoa 1 fica com B e a pessoa 3 com A, ao contrário da tabela measures. Os valores parecem plausíveis, mas pertencem à pessoa errada. Para combinar por chave, use merge() ou um join.