Primeira vez aqui? Seja bem vindo e cheque o FAQ!
x

Um exercício de Machine Learning usando a base "TMDB Box Office Prediction" do Kaggle

+1 voto
190 visitas
perguntada Dez 15, 2020 em Aprendizagem de Máquinas por Rodrigo Stuckert (66 pontos)  
editado Dez 16, 2020 por Rodrigo Stuckert

O The Movie Database (TMDB) é um portal da internet que conta com uma base de dados gratuita e de código aberto de séries de televisão e filmes. Neste exercício, vamos utilizar um banco de dados com diversas características de filmes com o objetivo de prever sua receita, em dólares americanos.

Link para a base de dados

Compartilhe

3 Respostas

+1 voto
respondida Dez 16, 2020 por Rodrigo Stuckert (66 pontos)  
editado Dez 19, 2020 por Rodrigo Stuckert

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

  • Data de lançamento

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))
  • Duração

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
  • Equipe e elenco

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")
+1 voto
respondida Dez 17, 2020 por Rodrigo Stuckert (66 pontos)  
editado Dez 19, 2020 por Rodrigo Stuckert
  • Produtoras, países de produção e gêneros

Para produtoras, países de produção e para gêneros, criei variáveis contando a quantidade de cada uma por filme (note que um filme pode ter mais de uma produtora e ter sido produzido em mais de um país, por exemplo).

Em relação aos países, criei uma dummy "produzido nos Estados Unidos", e outra "produzido em um dos outros 9 países mais recorrentes". Já para as produtoras e para os gêneros, criei dummies para cada um dos 10 valores mais recorrentes de cada uma.

  • Homepage e coleção

Por fim, criei dummies para se o filme tem ou não uma página da web, e se pertence ou não a uma coleção.

##############################
# PRODUCTION COUNTRIES, COMPANIES AND GENRA

# Renaming "production" to "prod", for the sake of simplicity
complete_df.rename(columns={"production_countries": "prod_countries", "production_companies": "prod_companies"}, inplace = True)

# Counter
for variable in ['prod_countries', 'prod_companies', 'genres']:
    complete_df[variable + '_count'] = complete_df[variable].apply(lambda x : len(x))

# Production companies and genres dummies
for variable in ['prod_companies', 'genres']:
    if variable == 'prod_companies':
        x = 11
    else:
        x = 10
    my_object = json_variables(complete_df, variable, x)
    complete_df = my_object.method(select = "dummy")

# Since all movies from "Columbia Pictures Corporation" are also counted as "Columbia Pictures" movies, we'll drop the first.
complete_df.drop(['prod_companies_Columbia Pictures Corporation'], axis = 1, inplace = True)


###### PRODUCTION COUNTRIES
my_object = json_variables(complete_df, 'prod_countries', 10)
top_10_countries = my_object.top_variable_names

# United States dummy
complete_df['prod_countries_all'] = complete_df['prod_countries'].apply(lambda x: ' '.join(sorted([i['name'] for i in x])) if x != {} else '')
complete_df['prod_countries_USA'] = complete_df['prod_countries_all'].apply(lambda x: 1 if 'United States of America' in x else 0)


def produced_on_other_top_10(x, top_10_countries):
    """ Returns 1 if the movie was produced in at least one of the other top 10
    most common countries, and 0 otherwise
    """
    # Excludes USA from the list
    other_top_10_countries = top_10_countries[1:]

    for country in other_top_10_countries:
        if country in x:
            return 1
        else:
            pass

    return 0


# Other top 10 countries dummy:
complete_df['prod_countries_other_top_10'] = complete_df['prod_countries_all'].apply(lambda x: produced_on_other_top_10(x, top_10_countries))


############# HOMEPAGE AND COLLECTION
# It is worth noting that about two-thirds of the movies have no homepage. Perhaps,
# this may be itself an useful information. Let's check it.

# Creates a variable for having a homepage
complete_df['has_homepage'] = 0
complete_df.loc[complete_df['homepage'].isnull() == False, 'has_homepage'] = 1

complete_df['has_homepage'].value_counts() # Counts the occurrences

# Collection dummy
complete_df['has_collection'] = complete_df['belongs_to_collection'].apply(lambda x: int(0) if x != x else int(1))

Análise Exploratória

Seguem os gráficos com a distribuição das principais variáveis, bem como seus boxplots em relação à receita:

################ DATE VARIABLES
# Year
sns.set_theme(style="whitegrid")
plt.xlabel("Year")
plt.title("Number of movies per year")
sns.histplot(data = complete_df, x = "year")
plt.show()

sns.lineplot(x="year", y="ln_revenue", data=complete_df)
sns.despine() # Removes some of the borders
plt.ylabel("Log-revenue")
plt.xlabel("Year")
plt.title("Mean movie's revenue per year")
plt.show()


# Released on friday?
sns.set_theme(style="whitegrid")
sns.countplot(data = complete_df, x = "release_weekday_friday", palette = "YlGnBu").set(xlabel = "Was the movie released on friday?", ylabel = "Frequency")
plt.show()

sns.boxplot(x="release_weekday_friday", y="ln_revenue", data=complete_df, palette = "YlGnBu")
plt.xlabel("Was the movie released on friday?")
plt.ylabel("Log-revenue")
sns.despine()
plt.show()

# Quarter
sns.set_theme(style="whitegrid")
sns.countplot(data = complete_df, x = "release_quarter", palette = "YlGnBu").set(xlabel= "Release quarter", ylabel = "Frequency")

sns.boxplot(x="release_quarter", y="ln_revenue", data=complete_df, palette = "YlGnBu").set(xlabel= "Release quarter", ylabel = "Log-revenue")
plt.title("Release quarter revenue boxplot")
sns.despine()
plt.show()


###################### BUDGET, REVENUE AND POPULARITY
sns.displot(data = complete_df, x = "ln_budget").set(xlabel= "Log-budget", ylabel = "Frequency", title = "Log-Budget distribution")
plt.show()

sns.displot(x = 'ln_revenue', data = complete_df, kind = 'kde')
plt.title("Log revenue distribution density")
plt.xlabel("Log revenue")
plt.show()


sns.histplot(data = complete_df, x='popularity')
plt.ylabel("Absolute frequency")
plt.xlabel("Popularity")
plt.show()

sns.histplot(data = complete_df, x='ln_popularity')
plt.xlabel("Log-popularity")
plt.ylabel("Absolute frequency")
plt.show()


###################### RUNTIME, CAST AND CREW
sns.histplot(data = complete_df, x = "budget_runtime_ratio").set(title='Runtime variable histogram', xlabel='Runtime', ylabel='Count')
sns.despine()
plt.show()

sns.histplot(data = complete_df, x = 'cast_size').set(title = 'Cast size histogram')
plt.show()

sns.histplot(data = complete_df, x = 'crew_size').set(title = 'Crew size histogram')
plt.show()

###################### PRODUCTION COUNTRIES, HOMEPAGE, BELONGS TO COLLECTION
sns.countplot(x='prod_countries_USA', data=complete_df, palette = "YlGnBu")
plt.ylabel("Absolute frequency")
plt.xlabel("Was the movie produced in the USA?")
plt.show()

sns.boxplot(x='prod_countries_USA', y='ln_revenue', data=complete_df, palette = "YlGnBu")
plt.title('Log-revenue for USA x non-USA prod. countries')
plt.ylabel("Log-revenue")
plt.xlabel("Prod. country == USA")
sns.despine()
plt.show()

sns.boxplot(x='prod_countries_other_top_10', y='ln_revenue', data=complete_df, palette = "YlGnBu")
plt.title('Log-revenue for other top 10 most common prod. countries')
plt.ylabel("Log-revenue")
plt.xlabel("Prod. country belongs to the other top 10 most frequent (USA incl.)?")
sns.despine()
plt.show()

# Homepage
sns.countplot(x='has_homepage', data=complete_df, palette = "YlGnBu")
plt.ylabel("Absolute frequency")
plt.xlabel("Does the movie have a homepage?")
plt.show()

sns.boxplot(x='has_homepage', y='ln_revenue', data=complete_df, palette = "YlGnBu")
plt.title('Revenue comparison for movies with and without homepages')
plt.ylabel("Log-revenue")
plt.xlabel("Does the movie have a homepage?")
plt.show()


sns.countplot(x='has_collection', data=complete_df, palette = "YlGnBu")
plt.ylabel("Absolute frequency")
plt.xlabel("Movie belongs to collection?")
plt.show()

sns.boxplot(x='has_collection', y='ln_revenue', data=complete_df, palette = "YlGnBu")
plt.ylabel("Log-revenue")
plt.xlabel("Movie belongs to collection?")
plt.show()

A imagem será apresentada aqui.
A imagem será apresentada aqui.
A imagem será apresentada aqui.
A imagem será apresentada aqui.
A imagem será apresentada aqui.
A imagem será apresentada aqui.
A imagem será apresentada aqui.
A imagem será apresentada aqui.
A imagem será apresentada aqui.
A imagem será apresentada aqui.
A imagem será apresentada aqui.
A imagem será apresentada aqui.
A imagem será apresentada aqui.
A imagem será apresentada aqui.
A imagem será apresentada aqui.
A imagem será apresentada aqui.
A imagem será apresentada aqui.
A imagem será apresentada aqui.

  • MAPA DE CORRELAÇÕES

O heatmap abaixo foi feito utilizando o pacote Pandas Profilling. Disponibilizo seu código em seguida. É visível que as variáveis mais correlacionadas com a receita dos filmes são o orçamento e a popularidade, apesar de o elenco e o staff também serem relevantes. Além disso, a quantidade de produtoras apresentou forte correlação com a quantidade de países em que o filme foi gravado.

A imagem será apresentada aqui.

from pandas_profiling import ProfileReport

# OBS: On the second line below this one, change "YOUR_DIRECTORY_HERE" to your directory
profile = ProfileReport(data, title='Relatório - Pandas Profiling', html={'style':{'full_width':True}})
#profile.to_file('YOUR_DIRECTORY_HERE/profile_report.html')  # Saves as a file
+1 voto
respondida Dez 18, 2020 por Rodrigo Stuckert (66 pontos)  
editado Dez 19, 2020 por Rodrigo Stuckert

MODELO

Dropando as variáveis desnecessárias e salvando o dataframe completo em um arquivo antes de partir para o modelo.

# Drops useless variables
df = complete_df.drop(['id','imdb_id', 'original_title', 'original_language', 'status', 'poster_path',
                  'belongs_to_collection', 'homepage', 'spoken_languages', 'tagline', 'overview',
                  'prod_countries', 'prod_companies', 'crew', 'cast', 'genres',
                  'belongs_to_collection', 'budget', 'Keywords', 'title', 'popularity','prod_countries_all',
                  'release_date', 'genres_count', 'release_weekday', 'release_quarter','revenue'],
                 axis = 1) # Axis = 1 means you're dropping a COLUMN, not a row

# Saving to excel file
df.to_excel('df.xlsx')
df.info()

Como nosso missing data ou foi eliminado, na variável receita, ou foi preenchido com informações de outras fontes, como na variável "runtime", ou até mesmo virou variável por si só ("pertence a coleção" e "tem homepage"), ficamos com um dataframe final em que todas as variáveis contam com exatamente 5338 observações, como podemos ver na imagem abaixo.

A imagem será apresentada aqui.

  • Train-test split

Pouco mais de 80% dos filmes da base foram produzidos nos Estados Unidos, e essa é uma variável com boa correlação com a nossa variável target. Dessa forma, para garantir que nossos datasets de treino e teste serão compatíveis entre si, optei por estratificá-los por essa variável.

# Models
from sklearn.linear_model import LinearRegression
from sklearn.ensemble import RandomForestRegressor
from sklearn.neighbors import KNeighborsRegressor

# Dimensionality reduction
from sklearn.preprocessing import StandardScaler # To use on X (features) before the PCA
from sklearn.decomposition import PCA

# Model selection
from sklearn import model_selection

# Performance metrics
from sklearn.metrics import r2_score, mean_squared_error, mean_squared_log_error


# Splitting the data stratifying by "prod country == USA"
df_train, df_test = model_selection.train_test_split(df, test_size = 0.3, random_state = 0, stratify = df['prod_countries_USA'])
print("Proportion of movies produced in the USA in the train dataset: ", df_train['prod_countries_USA'].mean())
print("Proportion of movies produced in the USA in the test dataset: ", df_test['prod_countries_USA'].mean())

Para rodar os modelos, criei uma classe MySklearningModel, que receberá as bases de dados, os modelos escolhidos e os respectivos parâmetros de interesse. Ela apresenta métodos para rodar cross-validation e as também as regressões com o treino e o teste. É possível ainda escolher se será feito Análise de Componentes Principais (PCA) antes dos modelos ou não.

class MySklearningModel:
    """ Object for the regression models. Accepts the use of PCA beforehand, and also
    has a method for running cross-validation    """

    def __init__(self, model, df_train, df_test, independent_variable_list, dependent_variable, 
                 use_pca = False, pca_variance = 0.95):
        """ Initiates

        model: model's type
        df_train, df_test: train and test dataframes
        independent_variable_list: features (X variables)
        dependent_variable: target (y variable)
        use_pca: whether to use or not PCA for dimensionality reduction (default: false)
        pca_variance: explained variance threshold for the PCA. Goes from zero (0%) to one (100%) (default: 0.95)
        """
        self.model = model
        self.independent_variable_list = independent_variable_list
        self.dependent_variable = dependent_variable
        self.X_train, self.X_test = df_train[self.independent_variable_list].values, df_test[self.independent_variable_list].values
        self.y_train, self.y_test = np.squeeze(df_train[[self.dependent_variable]].values), np.squeeze(df_test[[self.dependent_variable]].values)
        self.pca_variance = pca_variance

        # Dimensionality Reduction
        if use_pca == True:

            print("Initial number of dimensionalities: ", self.X_test.shape[1])

            # Rescaling before the PCA (necessary)
            my_scaler = StandardScaler()
            new_dataset = my_scaler.fit_transform(self.X_train) # Rescales
            self.X_test = my_scaler.transform(self.X_test) # Rescales

            pca = PCA(n_components = self.pca_variance)
            self.X_train = pca.fit_transform(new_dataset)
            self.X_test = pca.transform(self.X_test)

            print("Number of dimensionalities after PCA: ", self.X_test.shape[1])

        return None


    def run_sklearn_regression_crossval(self, number_splits, score_list, random_state = 0):
        """ Runs the regression (USE IT FOR CROSS-VALIDATION)

        number_splits: number of splits for the kfold
        score_list: performance's metrics to be used
        random_state: random seed (default = 0)
        """
        kfold = model_selection.KFold(n_splits=number_splits, shuffle=True, random_state=random_state)
        results = model_selection.cross_validate(self.model, self.X_train, self.y_train, cv=kfold, scoring=score_list,return_train_score=True)

        print(str(self.model))

        for score in score_list:
            print(score+':')
            print('Cross-val Train: '+'Mean',np.mean(results['train_'+score]),
            'Standard Error',np.std(results['train_'+score]))
            print('Cross-val Test: '+'Mean',np.mean(results['test_'+score]),
            'Standard Error',np.std(results['test_'+score]))
            print("")

    def run_sklearn_regression(self, score_list):
        """ Runs the regression """
        # Fits the model and predicts y
        self.model.fit(self.X_train, self.y_train)
        self.y_pred = self.model.predict(self.X_test)

        print(self.model)

        # Prints the performance metrics:
        for score in score_list:
            # If opted for MSE, returns RMSE
            if score == "mean_squared_error":
                print("root_" + score + ": " + str(eval(score + '(self.y_test, self.y_pred, squared = False)')))
            else:
                print(score + ": " + str(eval(score + '(self.y_test, self.y_pred)')))

        return None
  • Redução de dimensionalidade: PCA

Para reduzir o overfitting, optei pela redução de dimensionalidade, mais precisamente, pelo uso de Análise de Componentes Principais (PCA). O PCA gera novas variáveis, ortogonais entre si, por meio de combinações lineares das features originais. Essas novas variáveis (dimensões) são ordenadas de acordo com sua relevância para explicar a variância observada nos dados.

Como regra de bolso, costuma-se recomendar a escolha da quantidade de dimensões até que explique pelo menos 95% da variância nos dados. De fato, optei pelo limiar de 95%, o que representou aqui uma redução de 39 dimensões originalmente para 34.

Note que, para o uso adequado do PCA, primeiro deve-se normalizar os dados de treino (método "fit_transform" do sklearn.preprocessing.StandardScaler) e, em seguida, utilizar as mesmas informações de média e desvio-padrão das variáveis da base de treino na base de teste, aplicando o método "transform" apenas. Note que o *fit* deve ser feito apenas na base de treino.

De forma análoga, ao proceder com o PCA, deve-se fazer o fit apenas na base de treino, e aplicar o transform em ambas as bases. Aplicar o PCA na base inteira, antes do split, causaria vazamento de informação, "contaminando" a base de treino com informações do teste. Por outro lado, fazer um fit para cada uma das duas bases produziria componentes principais diferentes, apontando para direções distintas, o que não faria muito sentido.

Como observação final, é importante notar que tanto a normalização dos dados quanto o uso do PCA devem ser feitos apenas nas features, sem que se mexa na variável target.

  • Modelos utilizados e escolha dos parâmetros

O modelo que utilizei como benchmark foi a regressão linear simples. Além desse, utilizei também o KNeighbours Regressor e o Random Forest.

Fazendo uso de cross-validation, optei por adotar 10 vizinhos no KNeighbours Regressor. Após esse valor, os ganhos de performance foram marginais. Da mesma forma, encontrei o número ótimo de 100 estimadores e profundidade máxima de 11 para o regressor de Random Forest.

if __name__ == '__main__':

    number_splits = 5

    dependent_variable = 'ln_revenue'
    independent_variable_list = df.columns.values
    independent_variable_list = [x for x in independent_variable_list if x != dependent_variable]

    for model in [LinearRegression(), KNeighborsRegressor(n_neighbors = 10),
                  RandomForestRegressor(n_estimators = 100, max_depth = 11, random_state = 0)]:

        my_model = MySklearningModel(model,df_train, df_test, independent_variable_list,dependent_variable, 
                                     use_pca = True, pca_variance = 0.95)

        # Cross-validation
        #my_model.run_sklearn_regression_crossval(number_splits, ['neg_mean_squared_error', 'r2'])

        # Regression
        my_model.run_sklearn_regression(['mean_squared_error', 'r2_score'])

        print("\n\n")
  • Resultados

Os resultados obtidos na base de teste foram os seguintes:

  1. LinearRegression()
    rootmeansquarederror: 1.6427763453160076
    r2
    score: 0.5747025687856968

  2. KNeighborsRegressor(nneighbors=10)
    root
    meansquarederror: 1.7593583491077893
    r2_score: 0.5121969732166676

  3. RandomForestRegressor(maxdepth=11, randomstate=0)
    rootmeansquarederror: 1.6009727239867746
    r2
    score: 0.5960722003159162

De forma geral, curiosamente, o modelo de regressão linear, nosso benchmark, apresentou os melhores resultados na base de teste. O regressor de Random Forest apresentou performances excelentes na base de treino, mas acompanhados de resultados levemente inferiores que a regressão linear na base de teste (overfit).


REFERÊNCIAS

Pre-processing data before PCA
KNeighbors Regression
Random Forest Regressor

comentou Dez 18, 2020 por Gustavo Medeiros (46 pontos)  
editado Dez 18, 2020 por Gustavo Medeiros
Ótima abordagem Rodrigo, especialmente quanto à busca pelos dados da variável Target para completar a base de teste e fazer uma nova separação. E buscando sempre deixar os dois datasets balanceados. Como você mesmo disse, por se tratar um processo demorado e custoso, o fato de você já disponibilizar os dados em um zip no GitHub facilita muito o acesso para replicação e projetos similares.
A maior contribuição com certeza foi a classe MySklearningModel. Fiz um exercício com PCA também e vou tentar replicar essa classe no meu problema mais pra frente. Uma alternativa que me pareceu mais atraente do ponto de vista de efiência e estética do que a implementada originalmente.
Apenas um adendo: quando repliquei o código, obtive um erro para criar a coluna 'release_quarter', devido ao formato da coluna 'release_date'. Basta adicionar uma linha antes colando a coluna 'release_date' no formato de data que tudo se resolve.
Comando:
complete_df['release_date'] = pd.to_datetime(complete_df['release_date'])
comentou Dez 18, 2020 por Rodrigo Stuckert (66 pontos)  
Obrigado pelo comentário, Gustavo. Realmente, na hora de passar o exercício a limpo ficou faltando essa linha de código, acabei de fazer a correção! Forte abraço!
...