Findings - Sentimento

Qual o tom dominante e como ele evoluiu ao longo do ano

Author

Marlon

Published

May 7, 2026

Resumo

Três modelos de análise de sentimento foram rodados sobre a conversa (RoBERTa, DistilBERT, DeBERTa) e um ensemble consolidou os resultados. Esta análise responde duas perguntas:

  1. Qual modelo devemos confiar mais?
  2. O que ele diz sobre a conversa — tom dominante, evolução mensal, diferenças entre P1 e P2?

A resposta curta: o tom é dominantemente neutro (58%), com forte viés positivo quando pende (27%) e minoria negativa (15%). Essa distribuição é extraordinariamente estável ao longo do ano — quase não se move mês a mês.

WarningCaveat importante

Os 3 modelos foram treinados majoritariamente em inglês. A conversa é em português coloquial, com gírias, abreviações (mto, q, pq, kkkkk) e emojis. A análise é indicativa, não definitiva. Valores absolutos devem ser lidos com cautela; mudanças relativas e padrões são mais confiáveis.


Setup

Code
import pandas as pd
import numpy as np
import plotly.express as px
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from pathlib import Path
import warnings
warnings.filterwarnings('ignore')

from whatsapp.pipeline.config import DATA_FOLDER
DATA_DIR = Path("../data/processed") / DATA_FOLDER

df = pd.read_parquet(DATA_DIR / 'messages_with_models.parquet')
dfc = pd.read_parquet(DATA_DIR / 'messages_with_context.parquet')
df['fase_relacionamento'] = dfc['fase_relacionamento']
df = df[df['fase_relacionamento'] == 'Relacionamento'].copy()
df = df[df['conteudo'].notna() & (df['conteudo'] != '')]
df['timestamp'] = pd.to_datetime(df['timestamp'])

print(f"Mensagens analisadas: {len(df):,}")
Mensagens analisadas: 66,743

Os 3 modelos têm “personalidades” diferentes

Antes de escolher um, vale entender por que eles discordam tanto.

Code
def _norm(x):
    if pd.isna(x): return None
    x = str(x).lower()
    if 'pos' in x: return 'pos'
    if 'neg' in x: return 'neg'
    if 'neu' in x: return 'neu'
    return x

for col in ['sentimento_roberta_label', 'sentimento_distilbert_label', 'sentimento_deberta_label']:
    df[col+'_norm'] = df[col].apply(_norm)

models = ['roberta', 'distilbert', 'deberta', 'ensemble']
dist_rows = []
for m in models:
    counts = df[f'sentimento_{m}_label'].value_counts(normalize=True)
    for label in ['positive', 'neutral', 'negative']:
        dist_rows.append({
            'modelo': m.capitalize(),
            'classe': label,
            'pct': counts.get(label, 0) * 100
        })
dist = pd.DataFrame(dist_rows)

fig = px.bar(
    dist, x='modelo', y='pct', color='classe',
    color_discrete_map={'positive': '#2ECC71', 'neutral': '#95A5A6', 'negative': '#E74C3C'},
    barmode='stack', text=dist['pct'].round(1),
    height=400,
    title="Distribuição de sentimento por modelo"
)
fig.update_layout(yaxis_title="% de mensagens", xaxis_title="")
fig.update_traces(textposition='inside', textfont_size=11)
fig.show()

Leitura:

  • DeBERTa é um “neutralizador”: rotula ~94% como neutras. Quase não enxerga emoção no texto.
  • DistilBERT é um “polarizador”: rotula ~62% como positivas, 33% como negativas e praticamente zera o neutro. Força uma categorização binária.
  • RoBERTa é balanceado: ~49% neutras, 29% positivas, 23% negativas. Tem nuance.
  • Ensemble (voto majoritário dos 3) fica próximo do RoBERTa mas com mais neutros (58%) — os “empates” vão pro meio.

Concordância entre modelos

Code
unanime = (df['sentimento_roberta_label_norm']==df['sentimento_distilbert_label_norm']) & \
          (df['sentimento_roberta_label_norm']==df['sentimento_deberta_label_norm'])
pct_unanime = unanime.mean() * 100

def _maioria(row):
    from collections import Counter
    vals = [row['sentimento_roberta_label_norm'], row['sentimento_distilbert_label_norm'], row['sentimento_deberta_label_norm']]
    if any(v is None for v in vals): return 0
    return Counter(vals).most_common(1)[0][1]

maj = df.apply(_maioria, axis=1)
pct_maj = (maj >= 2).mean() * 100

print(f"Unanimidade (3/3 concordam): {pct_unanime:.1f}%")
print(f"Pelo menos 2/3 concordam:    {pct_maj:.1f}%")
Unanimidade (3/3 concordam): 6.2%
Pelo menos 2/3 concordam:    87.9%

Apenas 6,2% das mensagens têm 3/3 de concordância — reforço de que os modelos “veem coisas diferentes”. Em 87,9% das mensagens pelo menos 2 modelos concordam, o que dá solidez ao ensemble.


Qual modelo adotar?

Uso o Ensemble como referência para findings por três razões:

  1. Robusto a ruído individual: se um modelo tem personalidade extrema (DeBERTa), o voto majoritário mitiga.
  2. Concordância de maioria cobre ~88% dos casos — não é base frágil.
  3. Mantém a categoria “neutra” rica, reconhecendo que conversa real tem muito texto sem carga emocional explícita (logística, confirmações, memes).

A partir daqui, todas as análises usam sentimento_ensemble_label.


Tom dominante

Code
dist = df['sentimento_ensemble_label'].value_counts(normalize=True).reset_index()
dist.columns = ['classe', 'pct']
dist['pct'] = dist['pct'] * 100
dist['classe_pt'] = dist['classe'].map({'positive': 'Positivo', 'neutral': 'Neutro', 'negative': 'Negativo'})

fig = go.Figure(data=[go.Pie(
    labels=dist['classe_pt'],
    values=dist['pct'],
    hole=0.5,
    marker_colors=['#95A5A6' if c=='Neutro' else '#2ECC71' if c=='Positivo' else '#E74C3C' for c in dist['classe_pt']],
    text=dist['pct'].round(1).astype(str) + '%',
    textinfo='label+text',
    textfont_size=14
)])
fig.update_layout(
    height=400,
    title_text="Distribuição global do sentimento (ensemble)",
    annotations=[dict(text='89k<br>msgs', x=0.5, y=0.5, font_size=16, showarrow=False)]
)
fig.show()

A composição é:

  • 58% neutras — a maioria do que trocamos não carrega emoção clara detectável (logística, confirmações, pequenas observações cotidianas).
  • 27% positivas — humor, carinho, entusiasmo, piada.
  • 15% negativas — frustrações, queixas, dias difíceis.

Pender é positivo, na proporção ~1,8 : 1 (positivas vs. negativas). Não é uma conversa dominada por afeto explícito — é uma conversa cotidiana com viés afetuoso.


P1 vs. P2 — diferença de tom

Code
por_rem = df.groupby(['remetente', 'sentimento_ensemble_label']).size().unstack(fill_value=0)
por_rem_pct = por_rem.div(por_rem.sum(axis=1), axis=0) * 100

fig = go.Figure()
cores = {'positive': '#2ECC71', 'neutral': '#95A5A6', 'negative': '#E74C3C'}
rotulos = {'positive': 'Positivo', 'neutral': 'Neutro', 'negative': 'Negativo'}

for classe in ['positive', 'neutral', 'negative']:
    if classe in por_rem_pct.columns:
        fig.add_trace(go.Bar(
            name=rotulos[classe],
            x=por_rem_pct.index,
            y=por_rem_pct[classe],
            marker_color=cores[classe],
            text=por_rem_pct[classe].round(1).astype(str) + '%',
            textposition='inside'
        ))

fig.update_layout(
    barmode='stack',
    height=400,
    title_text="Distribuição do sentimento por remetente",
    yaxis_title="% de mensagens",
    xaxis_title=""
)
fig.show()

Diferenças sutis mas consistentes:

  • P1 é ligeiramente mais positivo: 29,8% vs. 24,8% de P2.
  • P2 é marginalmente mais negativo: 15,8% vs. 13,7% de P1.
  • P2 é mais neutro: 59,4% vs. 56,5% — comentários cotidianos “frios” pesam um pouco mais no estilo de P2.

Isso cruza com o notebook de estilos: P1 é o que usa mais emoji afetivos variados (😍 😘 🥰) e apelidos (“linda”, “baby”) — o que é capturado como “positivo”. P2 é mais abreviado e temporal, o que aparece como neutro para os modelos.


Evolução mensal — o tom mudou?

Code
df['ym'] = df['timestamp'].dt.to_period('M').astype(str)
mensal = df.groupby(['ym', 'sentimento_ensemble_label']).size().unstack(fill_value=0)
mensal_pct = mensal.div(mensal.sum(axis=1), axis=0) * 100

fig = go.Figure()
for classe, cor, rot in [('positive', '#2ECC71', 'Positivo'),
                          ('neutral', '#95A5A6', 'Neutro'),
                          ('negative', '#E74C3C', 'Negativo')]:
    if classe in mensal_pct.columns:
        fig.add_trace(go.Scatter(
            x=mensal_pct.index, y=mensal_pct[classe],
            mode='lines+markers',
            name=rot, line=dict(color=cor, width=3),
            marker=dict(size=8)
        ))

fig.update_layout(
    height=420,
    title_text="Sentimento mensal — evolução (%)",
    xaxis_title="Mês",
    yaxis_title="% das mensagens do mês",
    hovermode='x unified'
)
fig.show()

Este é o finding mais surpreendente da análise.

O tom é absurdamente estável:

  • Neutras ficam sempre entre 53,8% e 65,1%.
  • Positivas entre 23,3% e 29,7%.
  • Negativas entre 11,6% e 17,7%.

Nenhum mês se destaca dramaticamente. Mesmo com o volume mensal tendo oscilado de 3k a 17k mensagens, a proporção de positivo/neutro/negativo mal se mexe.

Pontos de atenção pequenos:

  • Setembro/2025 é o mês mais neutro (65,1%) e o menos positivo (23,3%) — pode refletir a queda geral de volume no mesmo mês.
  • Outubro/2025 tem o pico de negativas (17,7%) — maior do ano, mas ainda dentro do padrão.

A hipótese natural (“conversa esfriou no fim do relacionamento”) não aparece claramente no sentimento — aparece no volume (ver 08 - Dinâmica) mas não no tom.


Efeito de estarem juntos vs. separados

Code
por_enc = df.groupby(['em_encontro', 'sentimento_ensemble_label']).size().unstack(fill_value=0)
por_enc_pct = por_enc.div(por_enc.sum(axis=1), axis=0) * 100
por_enc_pct.index = por_enc_pct.index.map({True: 'Juntos', False: 'Separados'})

fig = go.Figure()
cores = {'positive': '#2ECC71', 'neutral': '#95A5A6', 'negative': '#E74C3C'}
rot = {'positive': 'Positivo', 'neutral': 'Neutro', 'negative': 'Negativo'}

for classe in ['positive', 'neutral', 'negative']:
    if classe in por_enc_pct.columns:
        fig.add_trace(go.Bar(
            name=rot[classe], x=por_enc_pct.index, y=por_enc_pct[classe],
            marker_color=cores[classe],
            text=por_enc_pct[classe].round(1).astype(str) + '%',
            textposition='inside'
        ))

fig.update_layout(barmode='stack', height=380,
                  title_text="Sentimento: juntos vs. separados",
                  yaxis_title="%")
fig.show()

Quando juntos, a conversa é mais neutra (62,5%) e um pouco menos positiva (24,5%) do que quando separados (57,1% neutro, 27,7% positivo). Isso faz sentido com a hipótese do notebook anterior: quando juntos, o texto é mais operacional/logístico (vídeos, memes, tarefas); a carga afetiva acontece presencialmente.


Conclusões

  1. O tom dominante é neutro com viés positivo. 58% neutro, 27% positivo, 15% negativo — positivas pesam cerca de 1,8× mais que negativas. Não é uma conversa de adjetivos amorosos todo o tempo; é cotidiana com afeto pontual.

  2. P1 é marginalmente mais “afetuoso” na escrita. Mais positivas (29,8% vs. 24,8%), menos negativas (13,7% vs. 15,8%). Coerente com o estilo de emojis e apelidos identificado em 11 - Estilos.

  3. O tom é notavelmente estável ao longo do ano. Diferente do volume (que oscila 3×), a proporção de cada classe mal se move. Nem o pico de volume (jun–jul) nem a queda do fim (set–out) produzem mudança grande de tom.

  4. A queda final de volume não é uma queda de tom. Setembro/outubro têm menos mensagens, mas o sentimento nelas permanece dentro do padrão. O sinal está no quanto se fala, não no como se fala.

  5. Juntos = mais neutro; separados = mais positivo (no texto). Quando juntos, o texto é operacional; separados, é o canal principal de afeto.

  6. Modelos desacordam bastante em valores absolutos (unanimidade em só 6,2% das mensagens), mas o ensemble é robusto (concordância de maioria em ~88%). Leia proporções e tendências, não números exatos.

TipVer também