Modelo de Machine Learning - Árvore de Decisões
Para esse projeto, foi utilizado um dataset obtido no Kaggle. Os dados usados podem ser baixados aqui.
Objetivo
O dataset apresenta diversos dados relacionados à cada um dos personagens da série de livros A Song of Ice and Fire, escrita por George R. R. Martin, inspiração para a famosa série Game of Thrones. O objetivo dessa análise é o modelo fazer a predição da importância do personagem para a série no sentido de trama. Uma variável categórica será criada a partir das variáveis presentes no dataset, classificando a relevância do personagem. Essa variável será avaliada pelo modelo de Machine Learning.
Workflow
Os pontos "etapas" são o passo-a-passo da realização do projeto.
Etapa 1 - Exploração de Dados
O dataset escolhido é composto por 1946 linhas e 30 colunas, contendo um personagem distinto em cada linha e diversas informações sobre cada um.
Colunas do dataset
Coluna | Tipo | Descrição |
---|---|---|
S.No | Inteiro | Identificador único do personagem |
plod | Float | Valor não especificado |
name | String | Nome do personagem |
title | String | Alcunha atribuída ao personagem dentro do mundo |
gender | Binário | Sexo do personagem: 0 = feminino, 1 = masculino |
culture | String | Grupo social ao qual o personagem pertence |
dateOfBirth | Inteiro | Data de nascimento. Valores positivos = depois do ano 0, negativos = antes do ano 0 |
DateoFdeath | Inteiro | Data de morte. Valores positivos = depois do ano 0, negativos = antes do ano 0 |
mother | String | Nome da mãe do personagem |
father | String | Nome do pai do personagem |
heir | String | Nome do herdeiro do personagem |
house | String | Nome da casa à qual o personagem pertence |
spouse | String | Nome do cônjuge do personagem |
book1 | Binário | Indica se o personagem apareceu no primeiro livro |
book2 | Binário | Indica se o personagem apareceu no segundo livro |
book3 | Binário | Indica se o personagem apareceu no terceiro livro |
book4 | Binário | Indica se o personagem apareceu no quarto livro |
book5 | Binário | Indica se o personagem apareceu no quinto livro |
isAliveMother | Binário | Indica se a mãe do personagem está viva |
isAliveFather | Binário | Indica se o pai do personagem está vivo |
isAliveHeir | Binário | Indica se o herdeiro do personagem está vivo |
isAliveSpouse | Binário | Indica se o cônjuge do personagem está vivo |
isMarried | Binário | Indica se o personagem é casado |
isNoble | Binário | Indica se o personagem é nobre |
age | Inteiro | Idade do personagem (referência: ano 305 D.C.) |
numDeadRelations | Inteiro | Número de personagens mortos com os quais o personagem se relaciona |
boolDeadRelations | Binário | Indica se há personagens mortos relacionados ao personagem |
isPopular | Binário | Indica se o personagem é considerado popular |
popularity | Float | Índice entre 0 e 1 que indica o quão popular é o personagem |
isAlive | Binário | Indica se o personagem está vivo |
Estudo da coluna plod
No dataset, temos uma coluna que possui um índice que aponta algo não identificado: o plod. Para investigar seu significado, são necessárias algumas análises:
- Inspeção dos valores: Primeiro, foram realizadas algumas linhas de código para verificar os valores da coluna;
Tipo de dado: float64
Valor mínimo: 0.0
Valor máximo: 1.0
Valor médio: 0.366
Exemplo de valor: 0.946
import pandas as pd
df = pd.read_csv("docs\decision-tree\dados.csv", sep=",", encoding="UTF-8")
print(f"Tipo de dado: {df["plod"].dtype}\n")
print(f"Valor mínimo: {df["plod"].min()}\n")
print(f"Valor máximo: {df["plod"].max()}\n")
print(f"Valor médio: {format(df["plod"].mean(), ".3f")}\n")
print(f"Exemplo de valor: {df.loc[0, "plod"]}")
A análise da saída obtida nos permite observar que os valores estão sempre no intervalo [0,1], sugerindo que representam uma probabilidade ou índice normalizado.
- Correlações entre
plod
e as outras colunas: Levando isso em consideração, é necessário realizar um cálculo de correlações para descobrir a principal variável no cálculo do plod:
popularity: 0.35458415491153905
isAliveFather: -0.3525990385007833
book4: -0.4041512952984149
isAlive: -0.41731839569897605
import pandas as pd
df = pd.read_csv("docs\decision-tree\dados.csv", sep=",", encoding="UTF-8")
df_numerico = df.select_dtypes(include=["number"])
correl = df_numerico.corr()["plod"].sort_values(ascending=False)
for col, corr in correl.items():
if (corr > 0.3 or corr < -0.3) and corr != 1:
print(f"{col}: {corr}\n")
É possível observar que a correlação mais forte entre plod e qualquer outra coluna no dataset é com a coluna isAlive. Esse dado nos permite criar uma hipótese de que plod é a estimativa da probabilidade de morte do personagem.
- Comparação com a coluna
isAlive
: Em seguida, para verificar a hipótese estabelecida, será feito um gráfico de boxplot para analisar a relação de plod e isAlive;
import matplotlib.pyplot as plt
import pandas as pd
from io import StringIO
df = pd.read_csv("docs\decision-tree\dados.csv", sep=",", encoding="UTF-8")
plod_alive = df[df["isAlive"] == 1]["plod"]
plod_dead = df[df["isAlive"] == 0]["plod"]
plt.rcParams["figure.figsize"] = (10, 5)
fig, ax = plt.subplots(facecolor="white")
ax.set_facecolor("white")
ax.boxplot([plod_alive, plod_dead], labels=["Vivo", "Morto"],
patch_artist=True,
boxprops=dict(facecolor="lightblue", color="black"),
medianprops=dict(color="red"))
ax.set_title("Distribuição de plod por estado de vida", color="black")
ax.set_ylabel("plod (probabilidade de morte)", color="black")
ax.set_xlabel("Estado de vida (isAlive)", color="black")
ax.grid(axis="y", linestyle="--", alpha=0.7, color="gray")
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
ax.spines["left"].set_color("black")
ax.spines["bottom"].set_color("black")
buffer = StringIO()
plt.savefig(buffer, format="svg", transparent=False)
print(buffer.getvalue())
No gráfico, observa-se que personagens vivos (isAlive = 1) tendem a possuir baixos valores de plod, enquanto personagens mortos (isAlive = 0) geralmente têm valores altos. Além disso, é possível observar diversos outliers dentre os personagens vivos, que são provavelmente personagens que aparecem pouco e/ou possuem informações incompletas. Essa ideia é fortalecida pelo fato de que a variável popularity (popularidade) também possui correlação moderada com plod.
Portanto, os padrões do gráfico indicam, novamente, que plod funciona como uma estimativa da probabilidade de morte do personagem, reforçando a hipótese inicial. Essa coluna provavelmente foi calculada com algum modelo preditivo anterior.
É necessário ressaltar que isso é uma observação exploratória, baseada nos dados disponíveis, e será considerada no pré-processamento e na escolha das features do modelo.
Exploração aprofundada da coluna popularity
- Estatísticas descritivas: Primeiramente, vamos calcular alguns valores essenciais dessa coluna;
Na saída, observa-se que popularity é um índice que indica a popularidade do personagem, variando entre 0 e 1, com o valor 0 para irrelevante e 1 para popular.
- Gráfico de dispersão de
popularity
: O gráfico relaciona o índice popularity com a soma das 5 variáveis book, que indicam a presença de um personagem em cada livro em binário. Os livros considerados nessas variáveis são apenas a narrativa principal da história, sem spin-offs e personagens que são apenas citados e referenciados.
import matplotlib.pyplot as plt
import pandas as pd
from io import StringIO
df = pd.read_csv("docs/decision-tree/dados.csv", sep=",", encoding="UTF-8")
df["book_freq"] = df[["book1", "book2", "book3", "book4", "book5"]].sum(axis=1)
plt.rcParams["figure.figsize"] = (10, 5)
fig, ax = plt.subplots(facecolor="white")
ax.set_facecolor("white")
ax.scatter(df["book_freq"], df["popularity"], alpha=0.7, color="red", edgecolor="black")
ax.set_title("Popularidade X Frequência nos livros", color="black")
ax.set_xlabel("Frequência em livros (soma)", color="black")
ax.set_ylabel("Popularidade", color="black")
ax.grid(axis="y", linestyle="--", alpha=0.7, color="gray")
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
ax.spines["left"].set_color("black")
ax.spines["bottom"].set_color("black")
buffer = StringIO()
plt.savefig(buffer, format="svg", transparent=False)
print(buffer.getvalue())
A análise do gráfico indica que, de 1 até 5 aparições, o número de personagens populares aumenta de forma diretamente proporcional, com alguns out-liers.
Contudo, podemos observar que diversos personagens que não apareceram na série principal de livros, possuindo soma de aparições igual a 0, são extremamente populares. Isso acontece pois há personagens de spin-offs muito amados pela comunidade, além de outros personagens que são apenas citados ao longo da história, sem aparecer diretamente, e também adquirem alta popularidade.
Etapa 2 - Pré-processamento
O objetivo do projeto é realizar uma predição da relevância dos personagens na trama principal, a variável categórica relevance que possuirá as seguintes categorias: Low, Medium, High e Very High.
1° Passo: Criação de book_freq
Primeiramente, é importante criar uma variável representante para a frequência em livros para cada personagem. Ao invés de utilizar 5 variáveis book diferentes, criaremos a variável boof_freq. Para isso, será feita a soma dos 5 valores das variáveis book, o que resultará em um intervalo de [0,5]. Contudo, para que esse valores sejam normalizados, e possuam um número entre 0 e 1, é feita a divisão desse resultado por 5.
2° Passo: Seleção de colunas
Em seguida, é necessário definir quais são as variáveis serão utilizadas para prever a relevância. Elas são as seguintes:
-
plod: Se esse valor for alto, há maior chance do personagem ser irrelevante
-
title: Se o personagem tiver um título, qualquer que seja, já possui alguma relevância
-
culture: Se o personagem tiver alguma cultura, qualquer que seja, já possui alguma relevância
-
mother: Se o personagem tiver paretentesco revelado, provavelmente tem alguma importância
-
father: Se o personagem tiver paretentesco revelado, provavelmente tem alguma importância
-
heir: Se o personagem tiver paretentesco revelado, provavelmente tem alguma importância
-
house: Se o personagem tiver uma casa, deve ser mais relevante
-
book_freq: Se o personagem aparece frequentemente, deve ser relevante
-
isNoble: Se o personagem for um nobre, tem mais chances de ser importante
-
popularity: Se o personagem for popular, também aumenta sua chance de relevância
A seleção das colunas foi feita, em código, da seguinte forma:
cols = ["plod", "title", "culture", "mother", "father", "heir", "house",
"book_freq", "isNoble", "popularity"]
df = df[cols]
3° Passo: Tratamento de valores faltantes
Precisamos garantir que não existam valores faltantes no dataframe. Por isso, será feita uma alteração em todas as linhas restantes que possuem valor NA. Contudo, temos que tratar diferentemente cada tipo de variável para o preenchimento dos vazios. As regras utilizadas serão as seguintes:
-
Faltantes númericos serão preenchidos com a mediana da coluna - plod, popularity
-
Faltantes categóricos nominais serão preenchidos com "Unknown" (Desconhecido) - title, culture, mother, father, heir, house
-
Faltantes binários serão preenchidos com a moda da coluna (valor mais frequente) - isNoble
cols = ["plod", "popularity"]
for col in cols:
df.fillna({col: df[col].median()}, inplace=True)
cols = ["title", "culture", "mother", "father", "heir", "house"]
for col in cols:
df.fillna({col: "Unknown"}, inplace=True)
df.fillna({"isNoble": df["isNoble"].mode()[0]}, inplace=True)
4° Passo: Binarização dos categóricos nominais
As variáveis categóricas no dataframe tem, simplesmente, muitas categorias para a realização de Label ou One-hot Encoding. Além disso, o único dado importante provindo dessas no modelo sendo criado é se existem ou não essas informações sobre o personagem. Portanto, as colunas title, culture, mother, father, heir e house serão binarizadas. Ou seja, se possuírem um valor, assumirão o valor 1. Caso contrário, 0.
Além disso, os nomes das colunas serão alterados, adicionando um "has_" antes do nome original da variável.
cols = ["title", "culture", "mother", "father", "heir", "house"]
for col in cols:
df[f"has_{col}"] = (df[col] != "Unknown").astype(int)
df.drop(columns=[col], inplace=True)
5° Passo: Inversão e renomeação de plod
A variável plod, que indica a probabilidade de morte, possui uma relação inversamente proporcional à relevância do personagem. Por isso, é necessária a inversão dessa variável. Além disso, renomear a variável para survival_prob deixará mais claro o seu propósito.
6° Passo: Criação da variável target relevance_category
a partir do score relevance_score
Agora, precisamos criar a variável categórica que será avaliada pelo modelo. Utilizaremos a seguinte distribuição de pesos:
-
popularity: Popularidade - 25%
-
book_freq: Frequência de aparições - 25%
-
survival_prob: Probabilidade de sobrevivência - 15%
-
isNoble: É nobre - 10%
-
has_title: Tem um título - 10%
-
has_house: Possui uma casa - 5%
-
has_culture: Tem uma cultura - 5%
-
has_mother + has_father + has_heir: Possui parentesco - 5%
Com o relevance_score definido, criaremos a nova coluna, relevance_category, a partir dos seguintes valores de score:
-
x < 0.25: Low (Baixa relevância)
-
0.25 <= x < 0.5: Medium (Relevância média)
-
0.5 <= x < 0.75: High (Alta relevância)
-
0.75 <= x <= 1: Very High (Relevância muito alta)
Resultado final do pré-processamento
Colunas após tratamento: ['book_freq', 'isNoble', 'popularity', 'has_title', 'has_culture', 'has_mother', 'has_father', 'has_heir', 'has_house', 'survival_prob', 'relevance_score', 'relevance_category']
Valores ausentes após pré-processamento: 0
Formato do dataset final: (1946, 12)
Features: 10
Target: relevance_category
Distribuição da variável target:
relevance_category | count |
---|---|
Medium | 1078 |
Low | 458 |
High | 379 |
Very High | 31 |
import pandas as pd
df = pd.read_csv("docs\decision-tree\dados.csv", sep=",", encoding="UTF-8")
# 1° Passo: Criando a coluna "book_freq", já normalizando-a
df["book_freq"] = df[["book1", "book2", "book3", "book4", "book5"]].sum(axis=1) / 5
# 2° Passo: Dropando colunas que não serão utilizadas
cols = [
"plod", "title", "culture", "mother", "father", "heir", "house", "book_freq", "isNoble", "popularity"
]
df = df[cols]
# 3° Passo: Tratamento de valores faltantes
cols = ["plod", "popularity"]
for col in cols:
df.fillna({col: df[col].median()}, inplace=True)
cols = ["title", "culture", "mother", "father", "heir", "house"]
for col in cols:
df.fillna({col: "Unknown"}, inplace=True)
df.fillna({"isNoble": df["isNoble"].mode()[0]}, inplace=True)
# 4° Passo: Binarização das variáveis categóricas nominais
cols = ["title", "culture", "mother", "father", "heir", "house"]
for col in cols:
df[f"has_{col}"] = (df[col] != "Unknown").astype(int)
df.drop(columns=[col], inplace=True)
# 5° Passo: Inversão da variável "plod" e renomeação para "survival_prob"
df["survival_prob"] = 1 - df["plod"]
df.drop(columns="plod", inplace=True)
# 6° Passo: Criar variável target "relevance_category" a partir do score "relevance_score"
def calculate_relevance_score(row):
score = (
row["popularity"] * 0.25 +
row["book_freq"] * 0.25 +
row["survival_prob"] * 0.15 +
row["isNoble"] * 0.10 +
row["has_title"] * 0.10 +
row["has_house"] * 0.05 +
row["has_culture"] * 0.05 +
(row["has_mother"] + row["has_father"] + row["has_heir"]) * 0.05 / 3
)
return min(max(score, 0), 1)
def categorize_relevance(score):
if score < 0.25:
return "Low"
elif score < 0.5:
return "Medium"
elif score < 0.75:
return "High"
else:
return "Very High"
df["relevance_score"] = df.apply(calculate_relevance_score, axis=1)
df["relevance_category"] = df["relevance_score"].apply(categorize_relevance)
features = [
"book_freq", "popularity", "survival_prob", "isNoble",
"has_title", "has_culture", "has_mother", "has_father",
"has_heir", "has_house",
]
target = "relevance_category"
# df.to_csv("dados_processado.csv", index=False)
print(f"Colunas após tratamento: {df.columns.tolist()}\n")
print(f"Valores ausentes após pré-processamento: {df.isnull().sum().sum()}\n")
print(f"Formato do dataset final: {df.shape}\n")
print(f"Features: {len(features)}\n")
print(f"Target: {target}\n")
print(f"Distribuição da variável target:\n")
print(df[target].value_counts().to_markdown())
Etapa 3 - Divisão de dados
Na etapa de divisão de dados, separaremos o conjunto de dados processado em dois grupos distintos:
-
Conjunto de Treino: É utilizado para ensinar o modelo a reconhecer padrões
-
Conjunto de Teste: É utilizado para avaliar o desempenho do modelo com dados ainda não vistos
Para realizar a divisão, utilizaremos a função train_test_split()
do scikit-learn
. Os parâmetros utilizados serão:
-
test_size=0.2: Define que 20% dos dados serão utilizados para teste, enquanto o restante será usado para treino.
-
random_state=42: Parâmetro que controla o gerador de número aleatórios utilizado para sortear os dados antes de separá-los. Garante reprodutibilidade.
-
stratify=y: Esse atributo definido como y é essencial devido à natureza da coluna relevance_category. Com essa definição, será mantida a mesma proporção das categorias em ambos os conjuntos, reduzindo o viés.
Treino: 1556 amostras
Teste: 390 amostras
Proporção: 80.0% treino, 20.0% teste
Distribuição das classes -
Treino:
relevance_category | count |
---|---|
Medium | 862 |
Low | 366 |
High | 303 |
Very High | 25 |
Teste:
relevance_category | count |
---|---|
Medium | 216 |
Low | 92 |
High | 76 |
Very High | 6 |
import pandas as pd
from sklearn.model_selection import train_test_split
df = pd.read_csv("dados_processado.csv")
features = [
"book_freq", "popularity", "survival_prob", "isNoble",
"has_title", "has_culture", "has_mother", "has_father",
"has_heir", "has_house"
]
target = "relevance_category"
x = df[features]
y = df[target]
x_train, x_test, y_train, y_test = train_test_split(x, y, test_size=0.2, random_state=42, stratify=y)
print(f"Treino: {x_train.shape[0]} amostras\n")
print(f"Teste: {x_test.shape[0]} amostras\n")
print(f"Proporção: {x_train.shape[0]/x.shape[0]*100:.1f}% treino, {x_test.shape[0]/x.shape[0]*100:.1f}% teste\n")
print("Distribuição das classes - \n")
print("Treino:\n")
print(y_train.value_counts().to_markdown(), "\n")
print("Teste:\n")
print(y_test.value_counts().to_markdown(), "\n")
Os dados, agora, estão devidamente divididos. Esta divisão adequada é de extrema importância, pois ajuda a evitar overfitting e garante que o modelo possa generalizar bem para novos personagens não vistos durante o treinamento.
Etapa 4 - Treinamento do Modelo
Agora, será realizado o treinamento do modelo. O objetivo dessa etapa é ensinar o algoritmo a reconhecer padrões nos dados que são fornecidos, e determinar a importância narrativa de cada personagem na série principal de livros de A Song of Ice and Fire.
Precisão do Modelo: 0.9513
Importância das Features:
Feature | Importância | |
---|---|---|
0 | book_freq | 0.439754 |
2 | survival_prob | 0.200915 |
3 | isNoble | 0.146227 |
1 | popularity | 0.102306 |
5 | has_culture | 0.054061 |
9 | has_house | 0.034825 |
4 | has_title | 0.021911 |
6 | has_mother | 0.000000 |
7 | has_father | 0.000000 |
8 | has_heir | 0.000000 |
import matplotlib.pyplot as plt
import pandas as pd
from sklearn import tree
from sklearn.metrics import accuracy_score
from sklearn.model_selection import train_test_split
from io import StringIO
df = pd.read_csv("dados_processado.csv")
features = [
"book_freq", "popularity", "survival_prob", "isNoble",
"has_title", "has_culture", "has_mother", "has_father",
"has_heir", "has_house"
]
target = "relevance_category"
x = df[features]
y = df[target]
x_train, x_test, y_train, y_test = train_test_split(x, y, test_size=0.2, random_state=42, stratify=y)
classifier = tree.DecisionTreeClassifier(random_state=42)
classifier.fit(x_train, y_train)
y_pred = classifier.predict(x_test)
accuracy = accuracy_score(y_test, y_pred)
print(f"Precisão do Modelo: {accuracy:.4f}")
feature_importance = pd.DataFrame({
"Feature": classifier.feature_names_in_,
"Importância": classifier.feature_importances_
})
print("<br>Importância das Features:")
print(feature_importance.sort_values(by="Importância", ascending=False).to_html() + "<br>")
plt.figure(figsize=(20, 10))
tree.plot_tree(
classifier,
feature_names=features,
class_names=classifier.classes_,
filled=True,
rounded=True,
max_depth=3,
fontsize=10
)
buffer = StringIO()
plt.savefig(buffer, format="svg")
print(buffer.getvalue())
Etapa 5 - Avaliação do modelo
Acurácia do modelo
O modelo alcançou uma acurácia impressionante de 95,13% no conjunto teste, demonstrando uma ótima capacidade de previsão com personagens ainda não vistos com base nas features escolhidas.
Importância das features
A análise da importância das features revela quais foram as variáveis mais importantes para a previsão e decisões do modelo:
Feature | Importância | Descrição |
---|---|---|
book_freq | 43,98% | Frequência de aparição nos livros da série principal |
survival_prob | 20,09% | Probabilidade de sobrevivência |
isNoble | 14,62% | Status nobre |
popularity | 10,23% | Índice de popularidade |
has_culture | 5,41% | Possui cultura conhecida |
has_house | 3,48% | Pertence a uma casa |
has_title | 2,19% | Tem algum título |
has_mother | 0,00% | Há informação sobre a mãe |
has_father | 0,00% | Há informação sobre o pai |
has_heir | 0,00% | Possui herdeiro |
Insights importantes sobre o modelo
-
Frequência em livros é determinante: A feature
book_freq
responde à aproximadamente 44% da importância, confirmando que personagens com mais aparições em diferentes livros da série principal possuem maior importância. -
Features desnecessárias: O modelo também demonstrou que algumas features (has_mother, has_father, has_heir) não têm nenhuma importância na predição.
Etapa 6 - Relatório Final
O projeto geral foi um sucesso, com a obtenção de um modelo com uma acurácia de 95,13%. O modelo, além de alta performance, possui features relevantes identificadas e bem estabelecidas: book_freq
, survival_prob
e isNoble
.
Limitações do modelo
Contudo, há limitações no modelo:
-
Features Redudantes: has_mother, has_father e has_heir possuem importância nula para a predição do sistema
-
Possível viés: É possível que, pela variável relevance_score ter sido manualmente estabelecida, pode haver viés
Possíveis melhorias
-
Validação de
plod
: Durante a primeira etapa, na exploração da base de dados, poderia ter sido feita uma Regressão Linear Múltipla completa para validar completamente a hipótese de que plod é a probabilidade de morte do personagem. -
Remoção de features desnecessárias: As features relacionadas à parentesco podem ser removidas do modelo sem nenhum impacto na predição.
Considerações finais
A árvore de decisão se mostrou muito capaz de fazer a predição de narrativas literárias complexas como A Song of Ice and Fire. Além do excelente resultado de acurácia, foram providos insights importantes sobre a obra pelo modelo.
Além disso, foi possível observar que tanto a Etapa 1 quanto a Etapa 2 foram muito mais longas do que as posteriores, demonstrando a importância de entender e limpar o dataset antes do uso.