A Netflix reúne filmes e séries de dezenas de países. Mas o que o cadastro do catálogo revela quando é examinado de perto? Filmes ou séries predominam? O acervo ficou mais internacional ao longo dos anos de inclusão? Que gêneros cada país traz para a plataforma, e onde o Brasil aparece nessa composição?
A pergunta central deste trabalho é: o que os registros do catálogo revelam sobre a composição, a internacionalização e a diversidade dos títulos da Netflix até setembro de 2021?
O tema interessa porque o catálogo é a vitrine da plataforma. Sua composição mostra escolhas de licenciamento e produção, o peso de mercados fora dos Estados Unidos e o espaço dado a cada gênero. Para quem estuda mídia ou trabalha com acervos audiovisuais, esses padrões ajudam a entender como uma grande plataforma organiza o que oferece.
O estudo utiliza o conjunto público Netflix Movies and TV Shows, disponibilizado por Shivam Bansal no Kaggle, com 8.807 registros e 12 colunas: tipo, título, direção, elenco, países, data de inclusão, ano de lançamento, classificação indicativa, duração, categorias e sinopse.
A metodologia é uma análise exploratória e reproduzível em R, organizada em três etapas: diagnóstico da qualidade dos dados, pré-processamento (limpeza, padronização, remoção de duplicatas e criação de variáveis) e exploração com gráficos e tabelas.
A abordagem central é desagregar o que o arquivo original
mistura. Países e categorias aparecem juntos numa mesma célula,
separados por vírgulas; datas estão em texto; durações misturam minutos
e temporadas; e há títulos repetidos que diferem apenas em letras
maiúsculas e minúsculas. Depois de separar e padronizar esses campos,
cruzamos as informações com duas tabelas auxiliares: uma de
continentes, obtida com o pacote
countrycode, e um dicionário de gêneros
que traduz e agrupa as 42 categorias originais. Esses cruzamentos
permitem perguntas que as colunas brutas não respondem, como a evolução
da participação de cada continente ou o perfil de gêneros de cada
país.
Pesquisadores de mídia e plataformas digitais encontram aqui um retrato da composição do catálogo. Profissionais de curadoria e licenciamento podem comparar o perfil de gêneros entre países, inclusive o do Brasil. E quem trabalha com metadados vê, na prática, como pequenos problemas de cadastro (uma duração na coluna errada, um título duplicado) alteram contagens e filtros. Os resultados descrevem o cadastro; não medem audiência nem popularidade.
Todos os pacotes são carregados antes da importação. Se algum estiver faltando, o relatório informa qual instalar.
| Pacote | Finalidade |
|---|---|
readr |
Importar o CSV e converter texto em número. |
dplyr |
Filtrar, transformar, agregar e juntar tabelas. |
tidyr |
Separar campos com múltiplos valores e alternar entre formato longo e largo. |
stringr |
Padronizar e verificar padrões em textos. |
lubridate |
Converter datas em texto e extrair anos. |
countrycode |
Associar cada país ao seu continente (tabela auxiliar para junção). |
ggplot2 |
Construir os gráficos. |
scales |
Formatar eixos e percentuais. |
plotly |
Tornar os gráficos interativos. |
DT |
Apresentar tabelas com busca, filtros e paginação. |
tidytext |
Separar as sinopses em palavras para análise textual. |
knitr |
Exibir tabelas formatadas no relatório. |
pacotes <- c(
"readr", "dplyr", "tidyr", "stringr", "lubridate", "countrycode",
"ggplot2", "scales", "plotly", "DT", "tidytext", "knitr"
)
faltantes <- setdiff(pacotes, rownames(installed.packages()))
if (length(faltantes) > 0) {
stop("Instale os pacotes ausentes: install.packages(c(",
paste(sprintf('"%s"', faltantes), collapse = ", "), "))")
}
invisible(lapply(pacotes, library, character.only = TRUE))
# Paleta única do relatório (lista nomeada reutilizada em todos os gráficos).
cores_relatorio <- list(
marinho = "#17365D", azul = "#2E74B5", verde = "#258B69",
mostarda = "#B07A00", vermelho = "#B94A48", roxo = "#6B5B95",
texto = "#273548", secundario = "#65758B", grade = "#E4EBF2"
)
cores_tipo <- c("Filme" = cores_relatorio$azul, "Série" = cores_relatorio$verde)
# Tema reutilizável: fundo branco, grade leve e títulos em azul-marinho.
tema_relatorio <- function(tamanho = 12) {
theme_minimal(base_size = tamanho) +
theme(
plot.title = element_text(color = cores_relatorio$marinho, face = "bold",
size = rel(1.1)),
plot.subtitle = element_text(color = cores_relatorio$secundario, size = rel(.88)),
axis.title = element_text(color = cores_relatorio$texto),
axis.text = element_text(color = cores_relatorio$secundario),
legend.title = element_text(color = cores_relatorio$marinho),
legend.text = element_text(color = cores_relatorio$texto),
panel.grid.major = element_line(color = cores_relatorio$grade, linewidth = .3),
panel.grid.minor = element_blank(),
plot.background = element_rect(fill = "white", color = NA),
panel.background = element_rect(fill = "white", color = NA)
)
}
theme_set(tema_relatorio())
# O ggplotly() descarta o subtítulo do ggplot. Esta função converte o gráfico
# e recoloca título e subtítulo no layout do plotly.
interativo <- function(grafico, titulo, subtitulo = NULL, tooltip = "text") {
texto_titulo <- paste0("<b>", titulo, "</b>")
if (!is.null(subtitulo)) {
texto_titulo <- paste0(texto_titulo, "<br><sup>", subtitulo, "</sup>")
}
plotly::ggplotly(grafico, tooltip = tooltip) %>%
plotly::layout(
title = list(text = texto_titulo, x = 0, xanchor = "left",
font = list(color = cores_relatorio$marinho)),
margin = list(t = 80)
) %>%
plotly::config(displaylogo = FALSE)
}
Fonte: BANSAL, Shivam. Netflix Movies and TV Shows. Kaggle. Disponível em: https://www.kaggle.com/datasets/shivamb/netflix-shows. Acesso em: 28 out. 2026. Licença CC0 (domínio público).
O conjunto foi criado para documentar os metadados dos títulos
disponíveis na Netflix. O autor o apresenta como um retrato do catálogo
em 2021, coletado a partir de uma ferramenta de busca de terceiros; a
data de inclusão mais recente no arquivo é de setembro de 2021. Os
valores ausentes estão registrados como campos vazios, que o R
interpreta como NA.
O arquivo é tabular, mas tem várias características que exigem tratamento: sinopses em texto livre, células com vários países ou categorias separados por vírgulas, datas em texto no formato americano, durações que misturam duas unidades e inconsistências de cadastro, descritas a seguir.
| Coluna original | Significado no arquivo |
|---|---|
show_id |
Identificador do registro. |
type |
Filme (Movie) ou série
(TV Show). |
title |
Nome do título. |
director |
Direção informada, quando disponível. |
cast |
Elenco informado, quando disponível. |
country |
País ou países de produção, separados por vírgulas. |
date_added |
Data de inclusão na plataforma, em texto (ex.: “September 25, 2021”). |
release_year |
Ano de lançamento original do título. |
rating |
Classificação indicativa americana (não é nota de usuários). |
duration |
Minutos (filmes) ou temporadas (séries), em texto. |
listed_in |
Categorias atribuídas pela Netflix, separadas por vírgulas. |
description |
Sinopse em texto livre, em inglês. |
# O CSV original fica em 'dados/' para que a análise possa ser reproduzida.
caminho <- file.path("dados", "netflix_titles.csv")
if (!file.exists(caminho)) stop("CSV não encontrado em: ", caminho)
# Tudo é lido como texto para que nenhuma conversão automática esconda problemas.
dados_originais <- readr::read_csv(
caminho, na = c("", "NA"), show_col_types = FALSE,
col_types = readr::cols(.default = readr::col_character(),
release_year = readr::col_integer())
)
stopifnot(nrow(dados_originais) > 5000, ncol(dados_originais) > 10)
stopifnot(!anyDuplicated(dados_originais$show_id))
O arquivo importado tem 8.807 linhas e 12 colunas, e
cada show_id é único.
Antes de alterar qualquer valor, medimos os problemas existentes. Isso define o que precisa ser tratado e permite comparar o antes e o depois.
# 1) Ausências por coluna.
diagnostico <- tibble::tibble(
coluna = names(dados_originais),
ausentes = vapply(dados_originais, function(x) sum(is.na(x)), integer(1))
) %>%
mutate(percentual = 100 * ausentes / nrow(dados_originais)) %>%
filter(ausentes > 0) %>%
arrange(desc(ausentes))
knitr::kable(
diagnostico, digits = 1,
col.names = c("Coluna", "Valores ausentes", "% dos registros"),
caption = "Colunas com valores ausentes no arquivo original"
)
| Coluna | Valores ausentes | % dos registros |
|---|---|---|
| director | 2634 | 29.9 |
| country | 831 | 9.4 |
| cast | 825 | 9.4 |
| date_added | 10 | 0.1 |
| rating | 4 | 0.0 |
| duration | 3 | 0.0 |
# 2) Inconsistências de cadastro.
duracao_em_rating <- sum(stringr::str_detect(dados_originais$rating, "min$"), na.rm = TRUE)
virgula_sobrando <- sum(stringr::str_detect(dados_originais$country, ",\\s*$"), na.rm = TRUE)
celulas_com_espacos <- sum(vapply(dados_originais, function(x) {
if (!is.character(x)) return(0L)
sum(x != stringr::str_squish(x), na.rm = TRUE)
}, integer(1)))
titulos_caixa <- dados_originais %>%
mutate(chave = stringr::str_squish(stringr::str_to_lower(title))) %>%
group_by(chave, type, release_year) %>% filter(n() > 1) %>% ungroup()
n_rotulos_rating <- dplyr::n_distinct(dados_originais$rating, na.rm = TRUE)
n_rotulos_categoria <- dados_originais$listed_in %>%
stringr::str_split(",") %>% unlist() %>% stringr::str_trim() %>% dplyr::n_distinct()
Além das ausências da tabela, o diagnóstico encontrou:
rating = “74 min” e
duration vazia);PG-13, R) e o de TV
(TV-14, TV-MA), com dois códigos para “sem
classificação” (NR e UR);Dramas e
TV Dramas).A ausência em director, cast e
country é informação que a fonte não forneceu. Não a
preenchemos por suposição, pois isso criaria dados falsos.
O quadro abaixo resume cada etapa, o motivo e quantos registros foram afetados. Os números são calculados pelo próprio código.
# Etapa 1. Padronizar espaços e converter textos vazios em NA.
dados <- dados_originais %>%
mutate(across(where(is.character), ~ na_if(stringr::str_squish(.x), "")))
# Etapa 2. Devolver a duração à coluna correta (somente nos casos verificáveis).
erro_duracao <- !is.na(dados$rating) &
stringr::str_detect(dados$rating, "^[0-9]+\\s+min$") &
is.na(dados$duration) & dados$type == "Movie"
dados$duration[erro_duracao] <- dados$rating[erro_duracao]
dados$rating[erro_duracao] <- NA_character_
# Etapa 3. Remover duplicatas que só diferem em maiúsculas/minúsculas ou espaços.
# Mantém o registro de inclusão mais recente, que representa o catálogo atual.
dados <- dados %>%
mutate(titulo_chave = stringr::str_squish(stringr::str_to_lower(title)),
data_inclusao = lubridate::mdy(date_added, quiet = TRUE)) %>%
group_by(titulo_chave, type, release_year) %>%
mutate(registros_no_grupo = n()) %>%
arrange(desc(data_inclusao), .by_group = TRUE) %>%
slice(1) %>% ungroup()
duplicatas_removidas <- nrow(dados_originais) - nrow(dados)
# Etapa 4. Unificar "sem classificação" e agrupar códigos em faixas de público.
dados <- dados %>%
mutate(
rating = if_else(rating == "UR", "NR", rating),
faixa_publico = case_when(
rating %in% c("TV-Y", "TV-Y7", "TV-Y7-FV", "G", "TV-G") ~ "Infantil e livre",
rating %in% c("PG", "TV-PG") ~ "Orientação dos pais",
rating %in% c("PG-13", "TV-14") ~ "Adolescente",
rating %in% c("R", "NC-17", "TV-MA") ~ "Adulto",
TRUE ~ "Não classificado"
),
faixa_publico = factor(faixa_publico, levels = c(
"Infantil e livre", "Orientação dos pais", "Adolescente", "Adulto", "Não classificado"))
)
# Etapa 5. Converter tipos e criar variáveis derivadas.
dados_limpos <- dados %>%
mutate(
tipo = factor(if_else(type == "Movie", "Filme", "Série"), levels = c("Filme", "Série")),
ano_inclusao = lubridate::year(data_inclusao),
ano_lancamento = release_year,
duracao_valor = readr::parse_number(duration),
minutos = if_else(type == "Movie", duracao_valor, NA_real_),
temporadas = if_else(type == "TV Show", duracao_valor, NA_real_),
intervalo_anos = ano_inclusao - ano_lancamento,
n_paises = if_else(is.na(country), NA_integer_,
stringr::str_count(stringr::str_remove(country, ",\\s*$"), ",") + 1L),
n_palavras_sinopse = stringr::str_count(description, "\\b[[:alpha:]]+\\b")
) %>%
select(-titulo_chave, -registros_no_grupo, -duracao_valor)
# Validações: nenhuma duração perdida e datas convertidas sem perda.
stopifnot(!anyDuplicated(dados_limpos$show_id))
stopifnot(all(!is.na(dados_limpos$minutos[dados_limpos$tipo == "Filme"])))
stopifnot(all(!is.na(dados_limpos$temporadas[dados_limpos$tipo == "Série"])))
stopifnot(sum(is.na(dados_limpos$data_inclusao)) == sum(is.na(dados_limpos$date_added)))
quadro_pre <- tibble::tribble(
~Etapa, ~Problema, ~Ação, ~Registros,
"1. Espaços e vazios", "Textos com espaços extras ou vazios",
"Remoção de espaços e conversão para NA", formatar_n(celulas_com_espacos),
"2. Coluna trocada", "Duração gravada em `rating`",
"Duração movida para `duration`; `rating` fica NA", formatar_n(sum(erro_duracao)),
"3. Duplicatas", "Mesmo título com caixa ou espaço diferente",
"Mantido o cadastro mais recente", formatar_n(duplicatas_removidas),
"4. Classificação", "`NR` e `UR` com o mesmo sentido; padrões de cinema e TV",
"Unificação e criação de `faixa_publico`", formatar_n(nrow(dados_limpos)),
"5. Tipos e novas variáveis", "Datas e durações em texto",
"Conversão para data/número e criação de 7 variáveis", formatar_n(nrow(dados_limpos))
)
knitr::kable(quadro_pre, caption = "Resumo do pré-processamento")
| Etapa | Problema | Ação | Registros |
|---|---|---|---|
| 1. Espaços e vazios | Textos com espaços extras ou vazios | Remoção de espaços e conversão para NA | 32 |
| 2. Coluna trocada | Duração gravada em rating |
Duração movida para duration;
rating fica NA |
3 |
| 3. Duplicatas | Mesmo título com caixa ou espaço diferente | Mantido o cadastro mais recente | 4 |
| 4. Classificação | NR e UR com o mesmo sentido;
padrões de cinema e TV |
Unificação e criação de faixa_publico |
8.803 |
| 5. Tipos e novas variáveis | Datas e durações em texto | Conversão para data/número e criação de 7 variáveis | 8.803 |
Após o pré-processamento, a base tem 8.803 títulos únicos. Nenhuma duração ficou ausente. Os 10 registros sem data de inclusão continuam na base, mas ficam fora das comparações por ano. A última inclusão registrada é de 25/09/2021.
As variáveis criadas foram: tipo (fator em português),
data_inclusao, ano_inclusao,
minutos, temporadas,
intervalo_anos (anos entre lançamento e inclusão),
n_paises, n_palavras_sinopse e
faixa_publico.
Contar diretamente as células de país ou categoria esconderia a participação individual de cada valor. Por isso, criamos tabelas em formato longo, com uma linha por par título–país e título–categoria, e as juntamos com duas tabelas auxiliares.
# Tabela longa de países: uma linha por par título-país.
# Cada título distribui peso total 1 entre seus países (contagem fracionada).
paises_longos <- dados_limpos %>%
select(show_id, tipo, ano_inclusao, country) %>%
filter(!is.na(country)) %>%
tidyr::separate_rows(country, sep = ",") %>%
mutate(pais = stringr::str_trim(country)) %>%
filter(pais != "") %>% # descarta o vazio da vírgula sobrando
distinct(show_id, tipo, ano_inclusao, pais) %>%
group_by(show_id) %>% mutate(peso = 1 / n()) %>% ungroup()
# Tabela auxiliar 1: país -> continente e nome em português (pacote countrycode).
continentes_pt <- c(Africa = "África", Americas = "Américas", Asia = "Ásia",
Europe = "Europa", Oceania = "Oceania")
tabela_continentes <- tibble::tibble(pais = sort(unique(paises_longos$pais))) %>%
mutate(continente = countrycode::countrycode(
pais, origin = "country.name", destination = "continent",
custom_match = c("East Germany" = "Europe", "Soviet Union" = "Europe")),
continente = unname(continentes_pt[continente]),
pais_pt = countrycode::countrycode(
pais, origin = "country.name", destination = "cldr.short.pt",
custom_match = c("East Germany" = "Alemanha Oriental",
"Soviet Union" = "União Soviética",
"West Germany" = "Alemanha Ocidental")))
stopifnot(!anyNA(tabela_continentes$continente), !anyNA(tabela_continentes$pais_pt))
paises_longos <- paises_longos %>% left_join(tabela_continentes, by = "pais")
# Tabela longa de categorias: uma linha por par título-categoria.
categorias_longas <- dados_limpos %>%
select(show_id, tipo, listed_in) %>%
tidyr::separate_rows(listed_in, sep = ",") %>%
mutate(categoria = stringr::str_trim(listed_in)) %>%
filter(categoria != "") %>%
distinct(show_id, tipo, categoria)
# Tabela auxiliar 2: dicionário que traduz e agrupa as categorias originais.
# 'natureza' separa gêneros de rótulos de origem/idioma e rótulos genéricos.
dicionario_categorias <- tibble::tribble(
~categoria, ~genero, ~natureza,
"Dramas", "Drama", "Gênero",
"TV Dramas", "Drama", "Gênero",
"Comedies", "Comédia", "Gênero",
"TV Comedies", "Comédia", "Gênero",
"Documentaries", "Documentário", "Gênero",
"Docuseries", "Documentário", "Gênero",
"Action & Adventure", "Ação e aventura", "Gênero",
"TV Action & Adventure", "Ação e aventura", "Gênero",
"Children & Family Movies", "Infantil e família", "Gênero",
"Kids' TV", "Infantil e família", "Gênero",
"Romantic Movies", "Romance", "Gênero",
"Romantic TV Shows", "Romance", "Gênero",
"Thrillers", "Suspense", "Gênero",
"TV Thrillers", "Suspense", "Gênero",
"Horror Movies", "Terror", "Gênero",
"TV Horror", "Terror", "Gênero",
"Sci-Fi & Fantasy", "Ficção científica e fantasia", "Gênero",
"TV Sci-Fi & Fantasy", "Ficção científica e fantasia", "Gênero",
"Anime Features", "Anime", "Gênero",
"Anime Series", "Anime", "Gênero",
"Crime TV Shows", "Crime", "Gênero",
"TV Mysteries", "Mistério", "Gênero",
"Music & Musicals", "Música e musicais", "Gênero",
"Stand-Up Comedy", "Stand-up e talk show", "Gênero",
"Stand-Up Comedy & Talk Shows", "Stand-up e talk show", "Gênero",
"Reality TV", "Reality show", "Gênero",
"Sports Movies", "Esportes", "Gênero",
"Science & Nature TV", "Ciência e natureza", "Gênero",
"Teen TV Shows", "Adolescente", "Gênero",
"LGBTQ Movies", "LGBTQ+", "Gênero",
"Faith & Spirituality", "Fé e espiritualidade", "Gênero",
"Classic Movies", "Clássicos e cult", "Gênero",
"Classic & Cult TV", "Clássicos e cult", "Gênero",
"Cult Movies", "Clássicos e cult", "Gênero",
"Independent Movies", "Independente", "Gênero",
"International Movies", "Internacional", "Origem/idioma",
"International TV Shows", "Internacional", "Origem/idioma",
"British TV Shows", "Britânico", "Origem/idioma",
"Korean TV Shows", "Coreano", "Origem/idioma",
"Spanish-Language TV Shows", "Língua espanhola", "Origem/idioma",
"Movies", "Sem gênero definido", "Genérico",
"TV Shows", "Sem gênero definido", "Genérico"
)
categorias_longas <- categorias_longas %>%
left_join(dicionario_categorias, by = "categoria")
stopifnot(!anyNA(categorias_longas$genero)) # toda categoria foi mapeada
# Formato largo: quantidade de títulos por ano de inclusão e tipo.
tabela_larga <- dados_limpos %>%
filter(between(ano_inclusao, 2016, 2020)) %>%
count(ano_inclusao, tipo) %>%
pivot_wider(names_from = tipo, values_from = n, values_fill = 0)
knitr::kable(tabela_larga, col.names = c("Ano de inclusão", "Filmes", "Séries"),
caption = "Formato largo: títulos por ano de inclusão e tipo")
| Ano de inclusão | Filmes | Séries |
|---|---|---|
| 2016 | 253 | 176 |
| 2017 | 839 | 349 |
| 2018 | 1235 | 412 |
| 2019 | 1423 | 591 |
| 2020 | 1284 | 595 |
As junções mantêm todos os registros: os 122 países foram associados a um continente e as 42 categorias originais foram agrupadas em 27 grupos. A contagem fracionada evita que um título de dois países conte como dois títulos completos.
Uma amostra resume a estrutura final; a tabela interativa permite buscar qualquer título sem imprimir milhares de linhas.
amostra <- dados_limpos %>%
transmute(ID = show_id, Tipo = tipo, `Título` = title,
`Lançamento` = ano_lancamento, `Inclusão` = ano_inclusao,
`Público` = faixa_publico, `Países` = n_paises) %>%
slice_head(n = 8)
knitr::kable(amostra, caption = "Oito primeiros registros da base preparada")
| ID | Tipo | Título | Lançamento | Inclusão | Público | Países |
|---|---|---|---|---|---|---|
| s2037 | Filme | #Alive | 2020 | 2020 | Adulto | 1 |
| s2305 | Filme | #AnneFrank - Parallel Stories | 2019 | 2020 | Adolescente | 1 |
| s2667 | Série | #blackAF | 2020 | 2020 | Adulto | 1 |
| s5973 | Filme | #cats_the_mewvie | 2020 | 2020 | Adolescente | 1 |
| s2482 | Filme | #FriendButMarried | 2018 | 2020 | Infantil e livre | 1 |
| s2325 | Filme | #FriendButMarried 2 | 2020 | 2020 | Infantil e livre | 1 |
| s5278 | Filme | #realityhigh | 2017 | 2017 | Adolescente | 1 |
| s5974 | Filme | #Roxy | 2018 | 2019 | Adolescente | 1 |
DT::datatable(
dados_limpos %>%
transmute(ID = show_id, Tipo = tipo, `Título` = title,
`Lançamento` = ano_lancamento, `Inclusão` = ano_inclusao,
`Classificação` = rating, `Público` = faixa_publico,
`Duração` = duration),
filter = "top", rownames = FALSE,
options = list(pageLength = 10, lengthMenu = c(10, 25, 50),
scrollX = TRUE, searchHighlight = TRUE),
caption = "Tabela interativa: busque e filtre os títulos da base limpa"
)
indicadores <- tibble::tibble(
Variável = c("Registros originais", "Títulos após o pré-processamento",
"Filmes / séries", "Ano de inclusão (intervalo)",
"Ano de lançamento (intervalo)", "Duração dos filmes (mediana)",
"Temporadas das séries (mediana)", "Títulos com país informado",
"Países distintos / continentes", "Grupos de gênero"),
Resumo = c(
formatar_n(nrow(dados_originais)),
formatar_n(nrow(dados_limpos)),
paste(formatar_n(sum(dados_limpos$tipo == "Filme")), "/",
formatar_n(sum(dados_limpos$tipo == "Série"))),
paste(range(dados_limpos$ano_inclusao, na.rm = TRUE), collapse = " a "),
paste(range(dados_limpos$ano_lancamento), collapse = " a "),
paste(median(dados_limpos$minutos, na.rm = TRUE), "minutos"),
median(dados_limpos$temporadas, na.rm = TRUE),
formatar_n(sum(!is.na(dados_limpos$country))),
paste(nrow(tabela_continentes), "/", n_distinct(tabela_continentes$continente)),
n_distinct(dicionario_categorias$genero[dicionario_categorias$natureza == "Gênero"])
)
)
knitr::kable(indicadores, caption = "Síntese das principais variáveis após o pré-processamento")
| Variável | Resumo |
|---|---|
| Registros originais | 8.807 |
| Títulos após o pré-processamento | 8.803 |
| Filmes / séries | 6.128 / 2.675 |
| Ano de inclusão (intervalo) | 2008 a 2021 |
| Ano de lançamento (intervalo) | 1925 a 2021 |
| Duração dos filmes (mediana) | 98 minutos |
| Temporadas das séries (mediana) | 1 |
| Títulos com país informado | 7.972 |
| Países distintos / continentes | 122 / 5 |
| Grupos de gênero | 22 |
As datas de inclusão se concentram nos últimos anos: 98,4% dos títulos datados entraram de 2016 em diante. Já os anos de lançamento são muito mais antigos e dispersos, o que indica que boa parte do catálogo é formada por obras licenciadas depois de lançadas.
A análise vai do retrato geral para cruzamentos mais detalhados. Todos os números referem-se a títulos cadastrados; títulos removidos antes de 2021 não aparecem no arquivo.
composicao <- dados_limpos %>%
count(tipo) %>% mutate(participacao = n / sum(n))
g_comp <- ggplot(composicao, aes(x = tipo, y = n, fill = tipo,
text = paste0(tipo, ": ", formatar_n(n), " títulos (",
formatar_pct(participacao), ")"))) +
geom_col(width = .6, show.legend = FALSE) +
geom_text(aes(label = formatar_n(n)), vjust = -0.5, size = 4) +
scale_fill_manual(values = cores_tipo) +
scale_y_continuous(expand = expansion(mult = c(0, .12))) +
labs(x = NULL, y = "Número de títulos")
interativo(g_comp, "Filmes são cerca de dois terços do catálogo",
"Quantidade de títulos cadastrados por tipo")
Dos 8.803 títulos, 69,6% são filmes e 30,4% são séries. Esse é o ponto de partida; os cruzamentos a seguir mostram que a proporção muda conforme o ano, o país e o gênero.
A comparação usa 2016 a 2020, anos completos e com volume suficiente; 2021 só vai até setembro.
por_ano <- dados_limpos %>%
filter(between(ano_inclusao, 2016, 2020)) %>%
count(ano_inclusao, tipo) %>%
group_by(ano_inclusao) %>% mutate(participacao = n / sum(n)) %>% ungroup()
g_ano <- ggplot(por_ano, aes(x = ano_inclusao, y = participacao, color = tipo, group = tipo,
text = paste0(ano_inclusao, " · ", tipo, ": ",
formatar_pct(participacao), " (", n, " títulos)"))) +
geom_line(linewidth = 1.1) + geom_point(size = 3) +
scale_color_manual(values = cores_tipo) +
scale_x_continuous(breaks = 2016:2020) +
scale_y_continuous(labels = scales::percent_format(accuracy = 1), limits = c(0, 1)) +
labs(x = "Ano de inclusão", y = "Participação", color = "Tipo")
interativo(g_ano, "A participação das séries caiu até 2018 e voltou a subir",
"Proporção de filmes e séries entre os títulos incluídos em cada ano")
serie_pct <- function(ano) {
formatar_pct(por_ano$participacao[por_ano$ano_inclusao == ano & por_ano$tipo == "Série"])
}
As séries eram 41,0% das inclusões de 2016, caíram para 25,0% em 2018 e subiram para 31,7% em 2020. Em 2018 a plataforma incorporou um grande volume de filmes, o que diluiu a participação das séries. A trajetória não é linear.
Esta é a pergunta que as colunas originais não respondem sozinhas. Ela exige separar os países, aplicar a contagem fracionada e juntar a tabela de continentes.
internacional_ano <- paises_longos %>%
filter(between(ano_inclusao, 2015, 2020)) %>%
group_by(ano_inclusao) %>%
summarise(fora_eua = sum(peso[pais != "United States"]) / sum(peso),
titulos = n_distinct(show_id), .groups = "drop")
continente_ano <- paises_longos %>%
filter(between(ano_inclusao, 2016, 2020)) %>%
group_by(ano_inclusao, continente) %>%
summarise(peso = sum(peso), .groups = "drop_last") %>%
mutate(participacao = peso / sum(peso)) %>% ungroup()
g_cont <- ggplot(continente_ano,
aes(x = ano_inclusao, y = participacao, color = continente, group = continente,
text = paste0(ano_inclusao, " · ", continente, ": ",
formatar_pct(participacao)))) +
geom_line(linewidth = 1.1) + geom_point(size = 2.6) +
scale_color_manual(values = c("Américas" = cores_relatorio$azul,
"Ásia" = cores_relatorio$vermelho,
"Europa" = cores_relatorio$verde,
"África" = cores_relatorio$mostarda,
"Oceania" = cores_relatorio$roxo)) +
scale_x_continuous(breaks = 2016:2020) +
scale_y_continuous(labels = scales::percent_format(accuracy = 1)) +
labs(x = "Ano de inclusão", y = "Participação ponderada", color = "Continente")
interativo(g_cont, "A Ásia ganhou espaço até 2018; a África cresceu a partir de 2019",
"Participação de cada continente entre os títulos incluídos em cada ano (contagem fracionada)")
fora <- function(ano) formatar_pct(internacional_ano$fora_eua[internacional_ano$ano_inclusao == ano])
cont <- function(ano, c) formatar_pct(continente_ano$participacao[
continente_ano$ano_inclusao == ano & continente_ano$continente == c])
knitr::kable(
internacional_ano %>% mutate(fora_eua = formatar_pct(fora_eua)),
col.names = c("Ano de inclusão", "Produção fora dos EUA", "Títulos com país"),
caption = "Participação ponderada de países que não os Estados Unidos"
)
| Ano de inclusão | Produção fora dos EUA | Títulos com país |
|---|---|---|
| 2015 | 32,9% | 79 |
| 2016 | 57,1% | 410 |
| 2017 | 63,9% | 1123 |
| 2018 | 66,4% | 1528 |
| 2019 | 59,3% | 1856 |
| 2020 | 60,0% | 1771 |
Em 2015, apenas 32,9% do peso dos títulos incluídos vinha de fora dos Estados Unidos. Já em 2016 esse valor saltou para 57,1% e chegou a 66,4% em 2018: desde 2016, a maior parte do que entra no catálogo é produzida fora dos EUA.
O avanço não foi uniforme entre continentes. A Ásia passou de 20,6% em 2016 para 38,3% em 2018, puxada principalmente pela Índia, e depois recuou. A África, quase ausente até 2018 (0,7%), chegou a 6,9% em 2020. A Europa perdeu participação relativa no período.
A variável intervalo_anos mede quantos anos se passaram
entre o lançamento original e a entrada na Netflix. Casos negativos (14
registros, provavelmente séries com temporadas posteriores) ficam fora
apenas deste gráfico.
intervalos_validos <- dados_limpos %>%
filter(!is.na(intervalo_anos), intervalo_anos >= 0) %>%
mutate(faixa = cut(intervalo_anos, breaks = c(-Inf, 1, 5, Inf),
labels = c("0 a 1 ano", "2 a 5 anos", "6 anos ou mais")))
faixas <- intervalos_validos %>%
count(tipo, faixa) %>% group_by(tipo) %>%
mutate(participacao = n / sum(n)) %>% ungroup()
g_intervalo <- ggplot(faixas, aes(x = tipo, y = participacao, fill = faixa,
text = paste0(tipo, " · ", faixa, ": ",
formatar_pct(participacao)))) +
geom_col() +
scale_fill_manual(values = c("0 a 1 ano" = cores_relatorio$azul,
"2 a 5 anos" = cores_relatorio$verde,
"6 anos ou mais" = cores_relatorio$mostarda)) +
scale_y_continuous(labels = scales::percent_format(accuracy = 1)) +
labs(x = NULL, y = "Participação em cada tipo", fill = "Intervalo")
interativo(g_intervalo, "Séries chegam à plataforma mais perto do lançamento",
"Anos entre o lançamento e a inclusão (somente intervalos não negativos)")
medianas <- intervalos_validos %>%
group_by(tipo) %>%
summarise(n = n(), mediana = median(intervalo_anos),
ate_um = mean(intervalo_anos <= 1), .groups = "drop")
knitr::kable(medianas %>% mutate(ate_um = formatar_pct(ate_um)),
col.names = c("Tipo", "Títulos analisados", "Mediana (anos)", "Até 1 ano"),
caption = "Intervalo entre lançamento e inclusão")
| Tipo | Títulos analisados | Mediana (anos) | Até 1 ano |
|---|---|---|---|
| Filme | 6126 | 2 | 49,6% |
| Série | 2653 | 0 | 67,3% |
67,3% das séries entram na plataforma em até um ano após o lançamento, contra 49,6% dos filmes. As séries tendem a ser lançamentos recentes ou produções próprias; os filmes incluem muitos títulos de acervo licenciados anos depois.
Com o dicionário de gêneros, Dramas e
TV Dramas passam a ser o mesmo grupo, o que permite
comparar filmes e séries lado a lado. Rótulos de origem (como
“Internacional”) e genéricos ficam fora desta comparação.
titulos_por_tipo <- dados_limpos %>% count(tipo, name = "total_tipo")
generos_tipo <- categorias_longas %>%
filter(natureza == "Gênero") %>%
distinct(show_id, tipo, genero) %>%
count(tipo, genero) %>%
left_join(titulos_por_tipo, by = "tipo") %>%
mutate(participacao = n / total_tipo)
top_generos <- generos_tipo %>%
group_by(genero) %>% summarise(total = sum(n), .groups = "drop") %>%
slice_max(total, n = 10) %>% pull(genero)
g_generos <- generos_tipo %>%
filter(genero %in% top_generos) %>%
mutate(genero = factor(genero, levels = rev(top_generos))) %>%
ggplot(aes(x = genero, y = participacao, fill = tipo,
text = paste0(genero, " · ", tipo, ": ", formatar_pct(participacao),
" dos títulos do tipo (", n, ")"))) +
geom_col(position = position_dodge(width = .8), width = .75) +
coord_flip() +
scale_fill_manual(values = cores_tipo) +
scale_y_continuous(labels = scales::percent_format(accuracy = 1)) +
labs(x = NULL, y = "% dos títulos do tipo", fill = "Tipo")
interativo(g_generos, "Drama e comédia dominam; crime e reality são marcas das séries",
"Dez grupos de gênero mais frequentes, em % dos filmes e das séries")
pct_gen <- function(g, t) formatar_pct(generos_tipo$participacao[
generos_tipo$genero == g & generos_tipo$tipo == t])
Drama está em 39,6% dos filmes e 28,5% das séries. Já o perfil das séries tem marcas próprias: crime aparece em 17,6% delas, e reality shows em 9,5%, grupos praticamente exclusivos da TV. Nos filmes, ação e aventura (14,0%) e o rótulo de independente têm peso maior.
contribuicoes <- paises_longos %>%
group_by(pais, pais_pt, continente) %>%
summarise(titulos_associados = n_distinct(show_id),
titulos_equivalentes = sum(peso), .groups = "drop") %>%
mutate(participacao = titulos_equivalentes / sum(titulos_equivalentes)) %>%
arrange(desc(titulos_equivalentes))
top_paises <- slice_head(contribuicoes, n = 10)
g_paises <- ggplot(top_paises, aes(x = reorder(pais_pt, titulos_equivalentes),
y = titulos_equivalentes, fill = continente,
text = paste0(pais_pt, ": ", formatar_n(round(titulos_equivalentes)),
" títulos equivalentes"))) +
geom_col() + coord_flip() +
scale_fill_manual(values = c("Américas" = cores_relatorio$azul,
"Ásia" = cores_relatorio$vermelho,
"Europa" = cores_relatorio$verde)) +
labs(x = NULL, y = "Títulos equivalentes (ponderados)", fill = "Continente")
interativo(g_paises, "Estados Unidos e Índia lideram os países de produção",
"Dez países com maior contribuição, com contagem fracionada")
knitr::kable(top_paises %>% select(-pais) %>%
mutate(titulos_equivalentes = round(titulos_equivalentes, 1),
participacao = formatar_pct(participacao)),
col.names = c("País", "Continente", "Títulos associados",
"Títulos equivalentes", "Participação ponderada"),
caption = "Dez países com maior contribuição ponderada")
| País | Continente | Títulos associados | Títulos equivalentes | Participação ponderada |
|---|---|---|---|---|
| EUA | Américas | 3689 | 3187.4 | 40,0% |
| Índia | Ásia | 1046 | 1003.2 | 12,6% |
| Reino Unido | Europa | 806 | 570.5 | 7,2% |
| Canadá | Américas | 445 | 288.1 | 3,6% |
| Japão | Ásia | 318 | 273.2 | 3,4% |
| França | Europa | 393 | 222.9 | 2,8% |
| Coreia do Sul | Ásia | 231 | 211.8 | 2,7% |
| Espanha | Europa | 232 | 178.9 | 2,2% |
| México | Américas | 169 | 135.5 | 1,7% |
| Alemanha | Europa | 226 | 122.1 | 1,5% |
Os Estados Unidos respondem por 40,0% do peso total, e a Índia, segunda colocada, por 12,6%. Juntos, os dez primeiros somam 77,7%. Os números consideram os 7.972 títulos com país informado.
O mapa de calor cruza os oito países com mais títulos e os principais grupos de gênero. Cada célula mostra a porcentagem dos títulos daquele país que pertencem ao gênero; como um título pode ter vários gêneros, as linhas não somam 100%.
top8 <- head(contribuicoes$pais, 8)
generos_mapa <- c("Drama", "Comédia", "Ação e aventura", "Documentário", "Romance",
"Infantil e família", "Suspense", "Terror", "Anime", "Crime")
titulos_pais <- paises_longos %>% filter(pais %in% top8) %>%
distinct(show_id, pais) %>% count(pais, name = "total_pais")
mapa <- paises_longos %>%
filter(pais %in% top8) %>% distinct(show_id, pais) %>%
inner_join(categorias_longas %>% filter(genero %in% generos_mapa) %>%
distinct(show_id, genero), by = "show_id") %>%
count(pais, genero) %>%
tidyr::complete(pais = top8, genero = generos_mapa, fill = list(n = 0)) %>%
left_join(titulos_pais, by = "pais") %>%
mutate(pct = n / total_pais,
pais_pt = factor(tabela_continentes$pais_pt[match(pais, tabela_continentes$pais)],
levels = rev(tabela_continentes$pais_pt[match(top8, tabela_continentes$pais)])),
genero = factor(genero, levels = generos_mapa))
# Gráfico estático (ggplot puro) para variar a forma de apresentação.
ggplot(mapa, aes(x = genero, y = pais_pt, fill = pct)) +
geom_tile(color = "white", linewidth = .8) +
geom_text(aes(label = formatC(100 * pct, format = "f", digits = 0)),
size = 3.4, color = ifelse(mapa$pct > .35, "white", cores_relatorio$texto)) +
scale_fill_gradient(low = "#F2F6FA", high = cores_relatorio$marinho,
labels = scales::percent_format(accuracy = 1)) +
labs(title = "O perfil de gênero muda bastante de um país para outro",
subtitle = "% dos títulos de cada país associados a cada grupo de gênero",
x = NULL, y = NULL, fill = "% do país") +
theme(axis.text.x = element_text(angle = 35, hjust = 1), panel.grid = element_blank())
pct_mapa <- function(p, g) formatar_pct(mapa$pct[mapa$pais == p & mapa$genero == g], 0)
O cruzamento revela especializações que nenhuma coluna mostra sozinha. O Japão concentra anime: 64% dos seus títulos. A Índia é fortemente voltada a drama (66%), enquanto a Coreia do Sul tem a maior presença de romance entre os oito países (34%). O Reino Unido lidera em documentários (27%) e o Canadá em infantil e família (32%), dois gêneros quase ausentes entre os títulos indianos (3% e 4%).
brasil <- dados_limpos %>%
filter(show_id %in% paises_longos$show_id[paises_longos$pais == "Brazil"])
geral_series <- mean(dados_limpos$tipo == "Série")
generos_brasil <- categorias_longas %>%
filter(show_id %in% brasil$show_id, natureza == "Gênero") %>%
distinct(show_id, genero) %>% count(genero, sort = TRUE) %>%
mutate(pct = n / nrow(brasil)) %>% slice_head(n = 5)
brasil_ano <- brasil %>% filter(!is.na(ano_inclusao)) %>% count(ano_inclusao)
knitr::kable(generos_brasil %>% mutate(pct = formatar_pct(pct)),
col.names = c("Gênero", "Títulos brasileiros", "% dos títulos brasileiros"),
caption = "Cinco gêneros mais frequentes entre títulos com participação brasileira")
| Gênero | Títulos brasileiros | % dos títulos brasileiros |
|---|---|---|
| Drama | 37 | 38,1% |
| Comédia | 26 | 26,8% |
| Documentário | 18 | 18,6% |
| Independente | 12 | 12,4% |
| Infantil e família | 10 | 10,3% |
g_brasil <- ggplot(brasil_ano, aes(x = factor(ano_inclusao), y = n,
text = paste0(ano_inclusao, ": ", n, " títulos"))) +
geom_col(fill = cores_relatorio$verde, width = .65) +
labs(x = "Ano de inclusão", y = "Títulos com participação brasileira")
interativo(g_brasil, "As inclusões de títulos brasileiros cresceram até 2020",
"Títulos com o Brasil entre os países de produção, por ano de inclusão (2021 até setembro)")
O catálogo tem 97 títulos com participação brasileira, 1,2% dos títulos com país informado. As séries são 32,0% desse grupo, acima da média geral de 30,4%. O gênero mais comum é drama, presente em 38,1% dos títulos. As inclusões saltaram de 2 em 2016 para 27 em 2020, acompanhando a internacionalização vista acima.
Minutos e temporadas são unidades diferentes, por isso cada tipo é analisado separadamente.
filmes <- dados_limpos %>% filter(tipo == "Filme")
series <- dados_limpos %>% filter(tipo == "Série")
g_filmes <- ggplot(filmes, aes(x = minutos)) +
geom_histogram(binwidth = 10, boundary = 0, fill = cores_relatorio$azul, color = "white") +
coord_cartesian(xlim = c(0, 240)) +
labs(x = "Duração em minutos", y = "Quantidade de filmes")
interativo(g_filmes, "A maioria dos filmes dura entre 80 e 130 minutos",
"Distribuição da duração (zoom até 240 minutos; nenhum dado foi removido)",
tooltip = c("x", "y"))
contagem_temporadas <- series %>%
mutate(grupo = factor(if_else(temporadas >= 6, "6 ou mais", as.character(temporadas)),
levels = c(as.character(1:5), "6 ou mais"))) %>%
count(grupo)
g_temporadas <- ggplot(contagem_temporadas,
aes(x = grupo, y = n, text = paste0(grupo, ": ", n, " séries"))) +
geom_col(fill = cores_relatorio$mostarda) +
labs(x = "Temporadas", y = "Quantidade de séries")
interativo(g_temporadas, "Duas em cada três séries têm uma única temporada",
"Número de temporadas informado no cadastro")
A duração mediana dos filmes é 98 minutos, e 71,3% deles ficam entre 80 e 130 minutos. Entre as séries, 67,0% têm apenas uma temporada, o que combina com o curto intervalo entre lançamento e inclusão: muitas séries entram no catálogo ainda novas.
A variável faixa_publico, criada no pré-processamento,
coloca os códigos de cinema e TV numa mesma escala.
publico <- dados_limpos %>%
count(tipo, faixa_publico) %>%
group_by(tipo) %>% mutate(participacao = n / sum(n)) %>% ungroup()
g_publico <- ggplot(publico, aes(x = tipo, y = participacao, fill = faixa_publico,
text = paste0(tipo, " · ", faixa_publico, ": ",
formatar_pct(participacao)))) +
geom_col(width = .6) +
scale_fill_manual(values = c("Infantil e livre" = cores_relatorio$verde,
"Orientação dos pais" = cores_relatorio$azul,
"Adolescente" = cores_relatorio$mostarda,
"Adulto" = cores_relatorio$vermelho,
"Não classificado" = "#B8C2CC")) +
scale_y_continuous(labels = scales::percent_format(accuracy = 1)) +
labs(x = NULL, y = "Participação em cada tipo", fill = "Faixa de público")
interativo(g_publico, "Conteúdo adulto é o maior grupo em filmes e séries",
"Classificação indicativa agrupada em faixas de público")
pct_pub <- function(f, t) formatar_pct(publico$participacao[
publico$faixa_publico == f & publico$tipo == t])
O conteúdo adulto responde por 46,7% dos filmes e 42,8% das séries. O infantil e livre tem peso maior nas séries (17,4%) do que nos filmes (7,2%), refletindo o volume de desenhos e programas infantis em formato seriado.
A coluna description é texto livre. Separamos as
sinopses em palavras, convertemos para minúsculas e removemos palavras
funcionais do inglês (stop words).
palavras <- dados_limpos %>%
select(show_id, tipo, description) %>%
filter(!is.na(description)) %>%
tidytext::unnest_tokens(palavra, description) %>%
filter(stringr::str_length(palavra) >= 4,
!stringr::str_detect(palavra, "^[0-9]+$")) %>%
anti_join(tidytext::stop_words, by = c("palavra" = "word"))
mais_frequentes <- palavras %>% count(palavra, sort = TRUE) %>% slice_head(n = 12)
g_palavras <- ggplot(mais_frequentes, aes(x = reorder(palavra, n), y = n,
text = paste0(palavra, ": ", n, " ocorrências"))) +
geom_col(fill = cores_relatorio$verde) + coord_flip() +
labs(x = NULL, y = "Ocorrências nas sinopses")
interativo(g_palavras, "Vida, família e amor são os temas mais citados nas sinopses",
"12 palavras mais frequentes, após remover palavras funcionais do inglês")
Após os filtros restam 105.071 ocorrências de 18.580 palavras distintas. Termos como life, family, love e friends mostram que as sinopses vendem os títulos pelas relações humanas, mais do que pela ação ou pelo cenário. A sinopse média tem 24 palavras, um formato curto e padronizado.
A pergunta era o que o cadastro revela sobre a composição, a internacionalização e a diversidade do catálogo da Netflix. A resposta tem três partes:
Os dados vieram de um único CSV público com 8.807 registros. O pré-processamento padronizou espaços em 32 células, corrigiu 3 durações gravadas na coluna errada, removeu 4 duplicatas que só diferiam em maiúsculas, minúsculas ou espaços, unificou a classificação indicativa em faixas de público e converteu datas e durações. Países e categorias foram separados em formato longo e cruzados com duas tabelas auxiliares (continentes e dicionário de gêneros). A exploração combinou gráficos interativos, um mapa de calor estático, tabelas e análise de texto.
Para pesquisadores de mídia, os dados mostram que a expansão internacional da Netflix aparece no catálogo, e não só no número de assinantes: a origem dos títulos mudou rapidamente entre 2015 e 2018. Para profissionais de curadoria e licenciamento, o mapa de calor indica em quais gêneros cada país é fornecedor relevante, e o recorte do Brasil mostra um mercado em crescimento com vocação para séries. Para quem trabalha com metadados, o projeto mostra como erros simples de cadastro (uma coluna trocada, um título duplicado com grafia diferente) alteram contagens se não forem tratados.
Como continuação, seria possível comparar retratos do catálogo de anos diferentes para medir entradas e saídas, incluir dados de audiência (como as listas semanais de mais vistos da Netflix) e aplicar modelagem de tópicos às sinopses para identificar temas além da contagem de palavras.
O código está dividido em etapas (importação, diagnóstico,
pré-processamento, tabelas auxiliares e análise), usa funções
reutilizáveis (interativo(), tema_relatorio(),
formatadores) e verifica os resultados com stopifnot().
Todo número citado no texto é calculado pelo código. Para reproduzir,
coloque o .Rmd e o estilo_relatorio.css na
pasta do projeto, o netflix_titles.csv em
dados/ e clique em Knit. O código de cada
etapa pode ser aberto pelo botão “Code”.