Obtenção dos dados
O banco de dados de train.csv conta inicialmente com 3000 observações, de 23 variáveis. No banco test.csv, temos mais 4398 observações, mas de filmes sem suas respectivas receitas. A base test.csv seria, em tese, o arquivo para a submissão de previsões dos participantes da competição; no entanto, optei por raspar da internet os dados das receitas dos filmes que aparecem nela para poder expandir nossa base. Esse procedimento será detalhado melhor na seção seguinte. As variáveis utilizadas nesse projeto foram as seguintes:
Variável dependente (target):
- Revenue: receita do filme em dólares correntes (deflacionaremos e aplicaremos o logaritmo natural)
Variáveis independentes (features):
- id: código de identificação de cada filme na base
- belongs to collection: informações sobre se o filme pertence a alguma saga (objeto JSON)
- budget: orçamento em dólares constantes. Obs: zero == desconhecido (missing)
- genres: gêneroS do filme (objeto JSON)
- homepage: link para o sítio da internet do filme, se existente
- imdb id: código de identificação do filme no IMDB
- popularity: medida de popularidade do filme em float (fonte não detalhada)
- production companies: produtoraS do filme (objeto JSON)
- production countries: paíseS em que o filme foi produzido (objeto JSON)
- release date: data de lançamento original do filme
- runtime: duração, em minutos
- cast: elenco do filme (objeto JSON)
- crew: staff ou equipe de produção do filme (objeto JSON)
Outras variáveis apareciam também no banco de dados, mas optei por trabalhar com essas, apenas.
# DataFrames and Arrays
import pandas as pd
import numpy as np
# Plots
import matplotlib.pyplot as plt
import seaborn as sns
# Other
import zipfile # To deal with zips
import ast # Parsing dictionary variables
import requests # Web scraping
import time # For the requests' sleep
from collections import Counter # Counts occurrences in dictionaries
import cpi # Inflation adjustments
from datetime import date
# Random seed
np.random.seed(0)
###########################
# The datasets
zf = zipfile.ZipFile('tmdb-box-office-prediction.zip')
train = pd.read_csv(zf.open('train.csv'))
test = pd.read_csv(zf.open('test.csv'))
###########################
# Columns that are JSON objects
dict_cols = ['genres', 'production_companies', 'production_countries', 'Keywords', 'cast', 'crew']
def text_to_dict(df):
""" Transforms JSON columns from strings to dictionaries. Indeed,
replaces the missing values with empty dictionaries """
for col in dict_cols:
df[col] = df[col].apply(lambda x: {} if pd.isna(x) else ast.literal_eval(x) )
return df
for df in [train, test]:
df = text_to_dict(df)
# A brief look at the data
train.head() # First five observations
train.info() # Variable types
Expansão do dataset por requests na API do TMDB
Para contar com uma base de dados maior, usarei a API do TMDB para buscar a receita dos filmes da base de dados test.csv e, em seguida, fazer o merge dela com a base train.csv, dando origem a um dataset "completo". A divisão dos dados desse dataset completo entre treino e teste para a modelagem será feita de todo modo, sem perda de generalidade.
A API do TMDB é uma interface gratuita que te permite fazer requests para obter dados de filmes, retornando tais informações em formato JSON. A única exigência para tanto é o uso de uma senha, que pode ser facilmente obtida criando uma conta de desenvolvedor (gratuita) no sítio do TMDB. AVISO IMPORTANTE: a execução do código abaixo demora cerca de sete horas, e estou disponibilizando-o apenas por questão de transparência.
def get_TMDB_id(imdb_id, api_key):
""" Returns the tmdb_id for a given imdb_id, using TMDB API's "find" method
imdb_id: self-explanatory
api_key: your TMDB API key.
"""
url = "https://api.themoviedb.org/3/find/" + imdb_id
# The parameters to be used on the request
querystring = {"api_key": api_key,
"language":"en-US",
"external_source":"imdb_id"}
# Making the request and parsing its text from JSON format to Python's dictionaries
response = requests.request("GET", url, params=querystring)
parsed_response = response.json()
# Getting the tmdb_id
tmdb_id = parsed_response['movie_results'][0]['id']
return str(tmdb_id)
def get_revenue(tmdb_id, api_key):
""" Given the movie's tmdb_id, returns its revenue, using TMDB API's "movie" method
tmdb_id: self-explanatory
api_key: your TMDB API key
"""
url = "https://api.themoviedb.org/3/movie/" + tmdb_id
querystring = {"api_key": api_key}
# Getting the request object
response = requests.request("GET", url, params=querystring)
# Parsing the information we want
parsed_response = response.json()
revenue = parsed_response['revenue']
return revenue
# Now, let's make the requests. It is worth noting that you will need your own API key in order to do so.
# Also, it is highly recommended to do a limited number of requests *per minute* to avoid overloading the system and, hence, being banned from it. I'll limit them to 20 *per minute*.
# Creating lists to receive the imdb_ids and also the revenues
test_imdb_ids = [x for x in test['imdb_id']]
the_revenues = [0] * len(test_imdb_ids)
# api_key = "" # YOUR API_KEY HERE
"""
# WARNING: the following code chunk requires a TMDB API key (which you can get for free on their website)
# It also takes about 7 hours to run. If you __REALLY__ want to run it, replace "if False:" on the following
# line of code to "if True:"
if False:
for i in range(len(test_imdb_ids)):
# Fill the values
print(i)
try:
next_tmdb_id = get_TMDB_id(test_imdb_ids[i], api_key)
the_revenues[i] = get_revenue(next_tmdb_id, api_key)
except:
print("An error occurred on observation ", i)
# Avoids excessive requests
# After 20 requests, stops the execution for one minute
# (remember: each loop counts as 2 requests)
if (i + 1) % 10 == 0:
print("Step: ", i)
time.sleep(60)
print("\nDone!")
# Saves the brand new data into an Excel file
#pd.DataFrame(the_revenues).to_excel('test_revenues.xlsx', header=True, index=False)
"""
NO ENTANTO, os dados de receita obtidos com a raspagem de dados supracitadas estão disponíveis no meu Github. O código abaixo já puxa esses dados, de forma rápida, e faz seu merge com as devidas bases, dando origem ao dataset completo, "complete_df".
# Loads the file
test['revenue'] = pd.read_excel("https://github.com/rstuckert3/movies_revenue_prediction/raw/main/datasets/test_revenues.xlsx")
test = test.drop(test[test.revenue == 0].index) # drops those movies for which the revenue was not available
# Merging the dataframes into a single df.
frames = [train, test]
complete_df = pd.concat(frames)
# Removes missing data from "budget" variable
complete_df = complete_df.loc[complete_df['budget'] != 0]
Apesar de o dataset completo contar com 7345 observações, cerca de 2000 delas contam com orçamento == 0, o que interpreto como dados faltantes. Após testar modelos fazendo a "imputação" dos dados faltantes dessa variável, optei por simplesmente eliminar essas observações, devido à melhor performance assim obtida. De fato, o orçamento é a variável com a maior correlação com a receita dos filmes na base de dados.
Tratamento dos dados
A data de lançamento dos filmes está no formato "mm/dd/yy"; iremos, então, transformá-la em um objeto "date", no formato "yyyy-mm-dd". A competição foi lançada em fevereiro de 2019, então pode-se afirmar que todos os filmes com anos terminados em 19 ou mais são do século passado. Indo além, como menos de vinte dos mais de 5000 filmes do dataset foram lançados entre 1920 e 1930, podemos considerar, com certa margem de segurança, os filmes com anos terminados em 18 ou menos como sendo do século presente.
Fazendo uso da data de lançamento, criei as variáveis "ano", "trimestre" (quarter) e "lançado numa sexta-feira?". Cheguei também a testar, por cross-validation, dummies de meses no lugar de trimestres, mas essa alternativa gerou 11 dummies muito desbalanceadas, resultando em pouco ganho de performance diante do overfitting adicional gerado. A escolha da variável "lançado numa sexta-feira?", por sua vez, se explica pelo fato de cerca de metade dos filmes terem sido lançados nesse dia da semana, não fazendo muito sentido criar uma dummy para cada dia.
def gen_year(x):
""" Returns the year from the date.
PS: the release date was originally a STRING on the "mm/dd/yy" format
"""
year = x.split('/')[2]
year = int(year)
return year
# Creating the YEAR variable
complete_df['year'] = 0
complete_df['year'] = complete_df['release_date'].apply(lambda x: gen_year(x))
# Counting the occurrences of years between 1920 and 1930
print("Absolute frequency of movies with release dates between 1920 and 1930")
complete_df.loc[(complete_df['year'] <= 30) & (complete_df['year'] >= 20)]['year'].value_counts()
def fix_year_release_date(release_date):
""" Adds 1900 or 2000 to the 'release_date' variable's year"""
year = release_date.split('/')[2] # Picks the year
# Corrects the year
if int(year) <= 19:
return release_date[:-2] + '20' + year
else:
return release_date[:-2] + '19' + year
# Corrects the 'year' column
complete_df['year'] = complete_df['year'].apply(lambda x: 1900 + x if x > 19 else 2000 + x)
complete_df['release_date'] = pd.to_datetime(complete_df['release_date'])
# Quarter and weekday
complete_df['release_quarter'] = complete_df['release_date'].dt.quarter
complete_df = pd.get_dummies(complete_df, columns=['release_quarter'], drop_first = True)
complete_df['release_quarter'] = complete_df['release_date'].dt.quarter
complete_df['release_weekday'] = complete_df['release_date'].dt.weekday
complete_df['release_weekday_friday'] = complete_df['release_weekday'].apply(lambda x: int(1) if x == 4 else int(0))
# Monday == 0, Friday == 4, Sunday == 6
complete_df['release_weekday'].head()
- Receita, orçamento e popularidade
Para a receita e o orçamento, corrigi-as pela inflação até a data do lançamento do filme mais recente da base de dados (agosto de 2018), e apliquei o logaritmo natural (ln) em ambas.
Para a popularidade, como ela apresenta uma distribuição com cauda longa à direita (muita concentração de filmes com baixa popularidade, e pouquíssimos com valores elevados), também apliquei o ln. Testei também o uso do quadrado e do cubo da popularidade no lugar, mas a forma funcional logarítmica apresentou melhores resultados no cross-validation.
# Getting the most recent movie release date
max_date = complete_df['release_date'].max()
# Inflation adjusting
complete_df['revenue'] = complete_df.apply(lambda x: cpi.inflate(value = x.revenue, year_or_month = x.release_date, to = max_date), axis = 1)
complete_df['budget'] = complete_df.apply(lambda x: cpi.inflate(value = x.budget, year_or_month = x.release_date, to = max_date), axis = 1)
# log1p(x) = ln(x+1): it avoids calculating log(0), which is undefined
complete_df['ln_revenue'] = complete_df['revenue'].apply(lambda x: np.log1p(x))
complete_df['ln_budget'] = complete_df['budget'].apply(lambda x: np.log1p(x))
# Ln(popularity)
complete_df['ln_popularity'] = complete_df['popularity'].apply(lambda x: np.log1p(x))
Pouco mais de uma dúzia dos filmes está ou com dados faltantes (NaN), ou com valor zero em sua duração, o que é virtualmente impossível. No entanto, essa informações puderam ser facilmente corrigidas checando na base do IMDB.
Em seguida, criei a variável "budget-runtime ratio", para pegar quanto foi investido por minuto de filme. Testei também colocar o quadrado da duração dos filmes, mas essa variável não trouxe ganhos significativos de performance.
# Finding out the movies with NaN (missing data) on runtime variable
complete_df['runtime'] = complete_df['runtime'].replace(0.0, np.nan) # Replacing 0 with NaN
complete_df.loc[complete_df['runtime'] != complete_df['runtime'], ['id', 'title', 'runtime', 'imdb_id']]
# Filling the runtime's missing values with IMDB's information
complete_df.loc[complete_df['id'] == 1336,'runtime'] = 130 # Korolyov
complete_df.loc[complete_df['id'] == 3244,'runtime'] = 93 # La caliente niña Julietta
complete_df.loc[complete_df['id'] == 4490,'runtime'] = 91 # Pancho, el perro millonario
complete_df.loc[complete_df['id'] == 4633,'runtime'] = 100 # Nunca en horas de clase
complete_df.loc[complete_df['id'] == 6818,'runtime'] = 90 # Miesten välisiä keskusteluja
complete_df.loc[complete_df['id'] == 391,'runtime'] = 96 # The Worst Christmas of My Life
complete_df.loc[complete_df['id'] == 978,'runtime'] = 93 # La peggior settimana della mia vita
complete_df.loc[complete_df['id'] == 1542,'runtime'] = 93 # All at Once
complete_df.loc[complete_df['id'] == 2151,'runtime'] = 108 # Mechenosets
complete_df.loc[complete_df['id'] == 2499,'runtime'] = 86 # Hooked on the Game 2. The Next Level
complete_df.loc[complete_df['id'] == 2866,'runtime'] = 96 # Tutto tutto niente niente
complete_df.loc[complete_df['id'] == 4074,'runtime'] = 103 # Shikshanachya Aaicha Gho
complete_df.loc[complete_df['id'] == 4431,'runtime'] = 96 # Plus one
complete_df.loc[complete_df['id'] == 5520,'runtime'] = 86 # Glukhar v kino
complete_df.loc[complete_df['id'] == 5849,'runtime'] = 140 # Shabd
complete_df.loc[complete_df['id'] == 6210,'runtime'] = 104 # The Last Breath
# Creating a "budget/runtime" ratio variable
complete_df['budget_runtime_ratio'] = complete_df.budget / complete_df.runtime
Para as variáveis crew e cast, criei uma variável com seus tamanhos, e dummies "top_50", que recebem 1 se o filme tiver pelo menos uma das 50 pessoas mais recorrentes na equipe/elenco na base de dados, e zero em caso contrário.
##############################
# CREW AND CAST
class json_variables(object):
""" Handles JSON (ie, dictionary) variables. """
def __init__(self, df, variable, top_number):
""" Initiates the class.
df: dataframe.
variable: the variable of interest (cast, crew, prod.companies, prod. countries or genre.)
top_number: threshold. Example: top_number = 30 means it will consider only the 30
most frequent cast / crew members / etc in the dataframe
"""
self.df = df
self.variable = variable
self.top_number = top_number
# Creates a list with each observation from that variable
self._list_of_obs = list(df[variable].apply(lambda x: [i['name'] for i in x] if x != {} else []).values)
# Counts the number of occurrences for the top "top_number" cast / crew members on the df,
# (dictionary-like list, with tuples containing the names followed by their counter)
self.top_variable = Counter([i for j in self._list_of_obs for i in j]).most_common(top_number)
# Grab only the cast / crew names, without their occurrences counter
self.top_variable_names = [x[0] for x in self.top_variable]
return None
def method(self, select):
""" Selects whether to call "generate_counter_var" or "generate_dummies"
select: selected method name (counter, dummy)
"""
if (select != "counter") and (select != "dummy"):
raise ValueError("Error. Selection variable must be either 'counter' or 'dummy'")
# Getting rid of "self"
variable = self.variable
top_number = self.top_number
# Creates new df to add the brand new variable
new_df = self.df
# Creates a new string variable containing all the crew / cast members on df
new_df[variable + '_all'] = new_df[variable].apply(lambda x: ' '.join(sorted([i['name'] for i in x])) if x != {} else '')
# Selection
if select == "counter":
new_df = self.generate_counter_var(variable, top_number, new_df)
else: # ie, if select == "dummy"
new_df = self.generate_dummies(variable, top_number, new_df)
# Removes support variables created
new_df.drop([variable + '_all'], axis = 1, inplace = True)
return new_df
def generate_counter_var(self, variable, top_number, new_df):
""" Adds a variable to the df counting how many "top_number" cast / crew members are there on each movie.
new_df: copy from the original df
"""
def occurrence_counter(df_variable, list_of_names):
""" Counts number of famous cast / crew members on each movie """
occurrences = 0
for person in list_of_names:
if person in df_variable:
occurrences += 1
return occurrences
# Applies the previously defined function
new_df[variable + '_top_' + str(top_number) + '_counter'] = 0
new_df[variable + '_top_' + str(top_number) + '_counter'] = new_df[variable + '_all'].apply(lambda x: occurrence_counter(x, self.top_variable_names))
return new_df
def generate_dummies(self, variable, top_number, new_df):
""" Creates dummies taking account if the movie belongs to the "top_number"
genra / or was developed by the "top_number" company
"""
# Creates dummy variables
for entry in self.top_variable_names:
new_df[variable + '_' + entry] = complete_df[variable + '_all'].apply(lambda x: 1 if entry in x else 0)
return new_df
# Size
for variable in ['crew', 'cast']:
complete_df[variable + '_size'] = complete_df[variable].apply(lambda x: len(x))
# Top_50 dummies
for variable in ['cast', 'crew']:
my_object = json_variables(complete_df, variable, 50)
complete_df = my_object.method(select = "counter")