radar-legislativo-lgbtqia-v2.1 / ensemble_híbrido.py
travahacker
refactor: rebrand para TybyrIA v2.1 - atualiza referências do modelo Radar Social
2b9be07
"""
Ensemble Híbrido: Combina AzMina + Radar Social + Keywords + Padrões
FASE 1 do Plano Revisado
"""
from transformers import pipeline
import torch
import re
from typing import Dict, Tuple, List
import pandas as pd
# Modelos
MODEL_RADAR = "Veronyka/tybyria-v2.1" # TybyrIA v2.1
MODEL_AZMINA = "azmina/ia-feminista-bert-posicao"
# Keywords (expandido baseado em análise dos resultados)
KEYWORDS_FAVORAVEIS = [
# Termos básicos
r"identidade de gênero", r"orientação sexual", r"lgbtqia\+", r"lgbt",
r"diversidade sexual", r"nome social", r"autodeterminação",
r"criminaliza.*homofobia", r"criminaliza.*transfobia",
r"proteção.*lgbt", r"direitos.*lgbt", r"igualdade.*gênero",
r"não discriminação", r"reconhecimento.*gênero",
r"características sexuais", r"expressão de gênero",
r"estatuto.*diversidade", r"transparência salarial.*orientação", r"misoginia.*orientação",
# Baseado no Radar Social - contexto positivo
r"proíbe.*terapias.*conversão", # Proibir terapias de conversão = favorável
r"equipara.*(terapia|terapias).*conversão.*(à|a).*tortura", # Equiparar terapias de conversão à tortura = favorável (PL 5034)
r"equipara.*(cura.*gay|terapia.*conversão).*tortura", # Variação
r"garante.*(direito|direitos).*(lgbt|trans|gay|orientação)",
r"reconhece.*(identidade|vivência|expressão)",
r"inclui.*(orientação|identidade).*(censo|dados|pesquisa)",
r"protege.*contra.*violência.*(lgbt|trans|gay)",
r"cria.*mecanismos.*proteção.*(lgbt|trans|orientação)",
r"visibilidade.*(lgbt|trans|diversidade)",
r"representação.*(lgbt|trans|diversidade)",
r"inclusão.*(lgbt|trans|diversidade)",
r"comunidade.*(lgbt|trans|diversidade).*direitos",
r"apoio.*(lgbt|trans|diversidade)",
r"respeito.*(identidade|vivência|expressão).*gênero"
]
KEYWORDS_DESFAVORAVEIS = [
# Básicos legislativos
r"sexo biológico", r"sexo de nascimento", r"ideologia de gênero",
r"proíbe.*gênero", r"veda.*gênero", r"restringe.*gênero",
r"valores familiares", r"proteção.*infância",
r"banheiro.*sexo", r"vestiário.*sexo", r"separar.*sexo",
r"exclusivamente.*(homem|mulher)", r"critério exclusivo.*sexo",
r"proíbe.*linguagem neutra", r"veda.*linguagem neutra",
r"proíbe.*educação sexual", r"atletas trans.*competições",
r"escola sem partido", r"unissex.*separado",
r"estatuto.*família", r"união.*(homem|mulher)", r"entre.*homem.*mulher",
# Baseado no Radar Social - termos específicos (versões flexíveis)
# Padrões com verbos (proíbe/veda) E substantivos (proibição/vedação)
r"(proíbe|veda|proibição|vedação).*(uso|exibição|porte).*(símbolo|símbolos|ícone).*religios.*(parada|paradas|lgbtqia|lgbtt|comunidade|evento|eventos)",
r"(proíbe|veda|proibição).*(uso|exibição).*(símbolo|símbolos).*religios.*(em|em paradas|nas paradas|de paradas).*(lgbtqia|lgbt|lgbtt)",
r"proibição.*(uso|do uso).*(símbolo|símbolos).*(crist|religios).*(lgbt|lgbtqia|parada|evento)",
r"(símbolo|símbolos).*(crist|religios).*(parada|paradas|lgbt|lgbtqia|evento|eventos).*(proíb|veta|veda)",
r"impede.*presença.*menor", r"proíbe.*menor.*evento", r"criança.*evento.*lgbt",
# Padrões específicos para PL 106 e PL 906 (alta precisão)
r"(impede|proíbe|veda).*(presença|participação|acesso).*(menor|menores|criança|crianças).*(evento|parada|manifestação|atividade).*(da|da comunidade).*(lgbtqia|lgbt|comunidade|diversidade)",
r"(impede|proíbe|veda).*(menor|criança).*(evento|parada|comemoração).*(lgbtqia|comunidade|diversidade)",
# Patologização (do Radar Social)
r"terapias.*conversão", r"cura.*gay", r"reparação.*sexual",
r"tratamento.*orientação", r"laudo.*psiquiátrico.*trans",
# Moralismo religioso em contexto legislativo
r"valores.*(cristão|religioso|bíblico).*educação",
r"sagrado.*família", r"família.*tradicional",
# Termos de redução/patologização
r"doença.*mental.*(trans|gay|lgbt)", r"transtorno.*(identidade|orientação)",
r"desvio.*(sexual|gênero)", r"anormalidade.*(sexual|gênero)",
# Restrições específicas
r"proíbe.*participação.*(trans|lgbt).*evento",
r"veda.*visibilidade.*(lgbt|gay|trans)",
r"restringe.*acesso.*(trans|lgbt).*espaço"
]
# Padrões legislativos desfavoráveis
# Padrões de ALTA PRIORIDADE (mais específicos e confiáveis)
PADROES_ALTA_PRIORIDADE = [
# PL 106: Símbolos religiosos em paradas LGBTQIA+ (verbos E substantivos)
r"(proíbe|veda|proibição|vedação).*(uso|do uso|exibição|porte).*(símbolo|símbolos|ícone).*religios.*(em|em paradas|nas paradas|de paradas).*(lgbtqia|lgbt|lgbtt)",
r"(proíbe|veda|proibição).*(símbolo|símbolos).*religios.*(parada|paradas|evento|eventos).*(lgbtqia|lgbt|comunidade)",
r"proibição.*(uso|do uso).*(símbolo|símbolos).*(crist|religios).*(lgbt|lgbtqia|parada|evento)",
r"(símbolo|símbolos).*(crist|religios).*(parada|paradas|lgbt|lgbtqia|evento|eventos).*(proíb|veta|veda|proibição)",
# PL 906: Impede menores em eventos LGBTQIA+ (verbos E substantivos)
r"(impede|proíbe|veda|proibição|vedação).*(presença|participação|acesso).*(menor|menores|criança|crianças).*(em|em eventos|nos eventos|de eventos|em paradas).*(da|da comunidade|lgbtqia|lgbt|comunidade|diversidade)",
r"(impede|proíbe|veda|proibição).*(menor|menores|criança|crianças).*(evento|parada|manifestação|atividade).*(lgbtqia|lgbt|comunidade)",
r"proibição.*(presença|da presença).*(menor|menores|criança|crianças).*(em|nos|de).*evento.*(da|da comunidade|lgbtqia|lgbt|comunidade)",
# PL 198/2023: Proibir linguagem neutra (NOVO)
r"(proíbe|proibir|veda|vedar|proibição|vedação).*(linguagem|uso|utilização).*(neutr|neutro|neutra)",
r"(proíbe|proibir|veda|vedar|proibição|vedação).*(linguagem neutra|pronome neutr|pronomes neutr)",
# PL 269/2023: Proibição de bloqueio puberal, cirurgias de redesignação (NOVO)
r"(proíbe|proibir|veda|vedar|proibição|vedação).*(bloqueio puberal|puberdade|hormônio|hormonal|cirurgia).*(trans|redesignação|transexual|transgênero|menor|menores|adolescent)",
r"(proíbe|proibir|proibição).*(terapia hormonal|cirurgia.*redesignação|redesignação sexual).*(menor|menores|criança)",
r"(proíbe|proibir|proibição).*(bloqueio.*puberal|hormonal).*processo transexualizador"
]
# Padrões normais (menos específicos, mas ainda importantes)
PADROES_RESTRITIVOS = [
r"define.*(sexo|gênero).*biolog", # "Define gênero por critérios biológicos"
r"(proíbe|proibição).*(ensino|divulgação).*gênero",
r"(veda|vedação).*uso.*por.*(pessoas|indivíduos).*(diferentes|diversos)",
r"exclusivamente.*(homem|mulher).*(cis|biologic)",
r"(restringe|limita|restrição).*participação.*(sexo|gênero)",
r"define.*entidade.*(homem|mulher)", # "Define entidade familiar como união entre homem e mulher"
r"(proíbe|proibição|impede|veda).*menor.*(evento|parada)", # Proíbe menores em eventos (genérico)
r"(proíbe|veda|proibição).*símbolo.*(religioso|parada|lgbt)", # Proíbe símbolos em paradas (genérico)
r"(proíbe|proibição).*linguagem", # Proíbe linguagem (genérico - pode pegar neutra)
r"(proíbe|proibição).*(bloqueio|hormônio|cirurgia).*(menor|adolescent)" # Restrições médicas para menores trans
]
def carregar_modelos():
"""Carrega ambos os modelos"""
print("📦 Carregando modelos...")
try:
radar = pipeline(
"text-classification",
model=MODEL_RADAR,
device=-1 # CPU
)
print(" ✅ Radar Social carregado")
except Exception as e:
print(f" ⚠️ Erro ao carregar Radar Social: {e}")
radar = None
try:
# AzMina não tem tokenizer_config.json no repositório, então usamos o tokenizer do modelo base
# Conforme README do modelo: base_model = neuralmind/bert-base-portuguese-cased
from transformers import AutoTokenizer, AutoModelForSequenceClassification
# Modelo base conforme documentado no README do repositório AzMina
base_model = "neuralmind/bert-base-portuguese-cased"
print(" 🔧 Carregando AzMina com tokenizer do modelo base...")
# Carregar tokenizer do modelo base (mesmo usado no treinamento do AzMina)
tokenizer = AutoTokenizer.from_pretrained(base_model)
# Carregar apenas o modelo AzMina (fine-tuned)
model = AutoModelForSequenceClassification.from_pretrained(MODEL_AZMINA)
# Criar pipeline combinando modelo AzMina + tokenizer do modelo base
# Isso é seguro porque o AzMina foi treinado com esse tokenizer específico
azmina = pipeline(
"text-classification",
model=model,
tokenizer=tokenizer,
device=-1 # CPU
)
print(" ✅ AzMina carregado")
except Exception as e:
error_msg = str(e)
print(f" ⚠️ Erro ao carregar AzMina: {error_msg[:150]}")
print(" ℹ️ Tentando método alternativo (pipeline direto)...")
try:
# Fallback: tentar pipeline direto (provavelmente falhará, mas tentamos)
azmina = pipeline(
"text-classification",
model=MODEL_AZMINA,
device=-1
)
print(" ✅ AzMina carregado (método alternativo)")
except Exception as e2:
print(f" ❌ AzMina não pôde ser carregado: {str(e2)[:100]}")
print(" ⚠️ Sistema funcionará apenas com Radar Social + Keywords + Padrões")
azmina = None
return radar, azmina
def extrair_keywords(texto: str) -> Tuple[int, int]:
"""Conta keywords favoráveis e desfavoráveis"""
texto_lower = texto.lower()
favoraveis = sum(1 for kw in KEYWORDS_FAVORAVEIS
if re.search(kw, texto_lower, re.IGNORECASE))
desfavoraveis = sum(1 for kw in KEYWORDS_DESFAVORAVEIS
if re.search(kw, texto_lower, re.IGNORECASE))
# EXCEÇÃO: "Criminaliza terapias de conversão" é favorável, não desfavorável
# Se tem "criminaliza" + "terapia de conversão", reduzir contagem desfavorável
if re.search(r'criminaliza.*terapia.*conversão', texto_lower, re.IGNORECASE):
desfavoraveis = max(0, desfavoraveis - 1) # Remover "terapia de conversão" da contagem desfavorável
favoraveis += 1 # Adicionar como favorável
return favoraveis, desfavoraveis
def detectar_padroes_restritivos(texto: str) -> float:
"""Detecta padrões legislativos desfavoráveis com pesos diferenciados"""
texto_lower = texto.lower()
# Padrões de alta prioridade (peso 2) - mais específicos e confiáveis
matches_alta = sum(1 for padrao in PADROES_ALTA_PRIORIDADE
if re.search(padrao, texto_lower, re.IGNORECASE))
# Padrões normais (peso 1)
matches_normais = sum(1 for padrao in PADROES_RESTRITIVOS
if re.search(padrao, texto_lower, re.IGNORECASE))
# BOOST FORTE: Se encontrou padrão de alta prioridade, dar score alto
# Esses padrões são muito específicos e indicam claramente desfavorável
if matches_alta > 0:
# Se tem match de alta prioridade, dar score mínimo de 0.99
# Com peso de 30%, isso contribui com 0.99 * 0.30 = 0.297 (~29.7%)
# Com outros sinais baixos (~25-35%), total chega a ~50%+ (DESFAVORÁVEL)
# Boost aumentado para garantir que PLs com padrões de alta prioridade sempre sejam DESFAVORÁVEIS
return max(0.99, min(0.995, 0.99 + (matches_alta * 0.002)))
# Score ponderado (alta prioridade vale mais)
# Normalizar considerando os pesos
total_peso_max = (len(PADROES_ALTA_PRIORIDADE) * 2) + len(PADROES_RESTRITIVOS)
score_atual = (matches_alta * 2) + matches_normais
# Normalizar para 0-1
score_normalizado = min(score_atual / total_peso_max, 1.0)
return score_normalizado
def classificar_ensemble(
texto: str,
radar_model,
azmina_model,
pesos: Dict[str, float] = None
) -> Dict:
"""Combina múltiplos sinais para classificar PL"""
if pesos is None:
# Pesos ajustados: dar mais peso a keywords e padrões (mais específicos para legislação)
# Se AzMina não estiver disponível, redistribuir seu peso proporcionalmente
if azmina_model is None:
# Sem AzMina: aumentar peso de keywords e padrões proporcionalmente
pesos = {
'radar': 0.20, # Detecção de ódio
'azmina': 0.0, # AzMina não disponível
'keywords': 0.40, # Aumentado de 0.35 para 0.40 (+0.05 do AzMina)
'padroes': 0.40 # Aumentado de 0.30 para 0.40 (+0.10 do AzMina)
}
else:
# Com ambos os modelos: distribuição otimizada
pesos = {
'radar': 0.20, # Detecção de ódio (menos relevante em legislação)
'azmina': 0.15, # Perspectiva feminista (proxy, não ideal) - REDUZIDO
'keywords': 0.35, # Keywords específicas (MAIS IMPORTANTE - legislação tem termos claros)
'padroes': 0.30 # Padrões legislativos (CRÍTICO para detectar restrições) - AUMENTADO
}
resultados = {}
# Sinal 1: Radar Social (detecção de ódio)
if radar_model:
try:
radar_result = radar_model(texto, truncation=True, max_length=256)
label = radar_result[0]['label']
score = radar_result[0]['score']
score_odio = 1 - score if label != 'HATE' else score
resultados['radar'] = score_odio
except:
resultados['radar'] = 0.5 # Neutro se erro
else:
resultados['radar'] = 0.5
# Sinal 2: AzMina (direitos de mulheres)
if azmina_model:
try:
azmina_result = azmina_model(texto, truncation=True, max_length=256)
# Assumindo que label 0 = desfavorável, 1 = favorável
# Ajustar baseado na documentação real do modelo
score_favoravel = azmina_result[0]['score'] if azmina_result[0]['label'] == 'LABEL_1' else 1 - azmina_result[0]['score']
resultados['azmina'] = 1 - score_favoravel # Inverter: menor = mais favorável
except:
resultados['azmina'] = 0.5
else:
resultados['azmina'] = 0.5
# Sinal 3: Keywords (com detecção de padrões favoráveis específicos)
texto_lower = texto.lower()
# Padrões de ALTA PRIORIDADE FAVORÁVEIS (boost negativo - diminui score)
padroes_favoraveis_alta = [
r"equipara.*(terapia|terapias).*conversão.*(à|a).*tortura", # PL 5034
r"equipara.*(cura.*gay|terapia.*conversão).*tortura",
r"equipara.*terapia.*conversão.*tortura"
]
tem_padrao_favoravel_alta = any(
re.search(padrao, texto_lower, re.IGNORECASE)
for padrao in padroes_favoraveis_alta
)
kw_fav, kw_desfav = extrair_keywords(texto)
total_kw = kw_fav + kw_desfav if (kw_fav + kw_desfav) > 0 else 1
if tem_padrao_favoravel_alta:
# Se tem padrão favorável de alta prioridade, forçar score baixo
score_keywords = 0.15 # Score muito baixo = muito favorável
else:
score_keywords = kw_desfav / total_kw # Mais keywords desfavoráveis = maior score
resultados['keywords'] = min(score_keywords, 1.0)
# Sinal 4: Padrões legislativos
padroes_score = detectar_padroes_restritivos(texto)
resultados['padroes'] = padroes_score
# Ajuste dinâmico: Se padrões de alta prioridade foram detectados, aumentar seu peso
# Isso garante que PLs com padrões críticos (ex: proibir símbolos em paradas, impedir menores)
# sejam sempre classificadas como DESFAVORÁVEIS
pesos_ajustados = pesos.copy()
if padroes_score >= 0.95: # Se padrão de alta prioridade foi detectado
# Redistribuir pesos: aumentar padrões, reduzir outros proporcionalmente
aumento_padroes = 0.10 # Aumentar padrões em 10%
pesos_ajustados['padroes'] = min(0.50, pesos['padroes'] + aumento_padroes)
# Reduzir outros proporcionalmente
outros_pesos = sum(v for k, v in pesos.items() if k != 'padroes')
fator_reducao = (outros_pesos - aumento_padroes) / outros_pesos
for k in pesos_ajustados:
if k != 'padroes':
pesos_ajustados[k] = pesos[k] * fator_reducao
# Combinar com pesos (ajustados se necessário)
score_final = (
pesos_ajustados['radar'] * resultados['radar'] +
pesos_ajustados['azmina'] * resultados['azmina'] +
pesos_ajustados['keywords'] * resultados['keywords'] +
pesos_ajustados['padroes'] * resultados['padroes']
)
# Classificar
if score_final >= 0.5:
classificacao = "DESFAVORÁVEL"
elif score_final >= 0.3:
classificacao = "REVISÃO"
else:
classificacao = "FAVORÁVEL"
return {
'classificacao': classificacao,
'score_final': score_final,
'sinais': resultados,
'pesos_usados': pesos,
'explicacao': f"""
Score Final: {score_final:.2%}
Contribuição de cada sinal:
- Radar Social (ódio): {resultados['radar']:.2%} (peso {pesos['radar']:.0%})
- AzMina (mulheres): {resultados['azmina']:.2%} (peso {pesos['azmina']:.0%})
- Keywords: {resultados['keywords']:.2%} (peso {pesos['keywords']:.0%})
- Padrões: {resultados['padroes']:.2%} (peso {pesos['padroes']:.0%})
"""
}
def testar_ensemble(dataset_path: str = "pls_processadas.csv"):
"""Testa o ensemble no dataset anotado"""
print("\n🧪 Testando Ensemble Híbrido...\n")
# Carregar modelos
radar, azmina = carregar_modelos()
# Carregar dataset
try:
df = pd.read_csv(dataset_path)
print(f"✅ Dataset carregado: {len(df)} PLs\n")
except:
print(f"❌ Erro ao carregar {dataset_path}")
return
# Classificar cada PL
resultados = []
for idx, row in df.iterrows():
ementa = str(row.get('Ementa', ''))
posicao_real = row.get('Posição', 'N/A')
if not ementa or ementa.strip() == '':
continue
resultado = classificar_ensemble(ementa, radar, azmina)
resultados.append({
'PL': row.get('Nº', f'PL {idx+1}'),
'Classificação Real': posicao_real,
'Classificação Predita': resultado['classificacao'],
'Score Final': resultado['score_final'],
**{f'Sinal_{k}': v for k, v in resultado['sinais'].items()}
})
# Calcular métricas
df_resultados = pd.DataFrame(resultados)
corretos = sum(
(df_resultados['Classificação Real'].str.contains('Favorável', case=False) &
df_resultados['Classificação Predita'].str.contains('FAVORÁVEL', case=False)) |
(df_resultados['Classificação Real'].str.contains('Desfavorável', case=False) &
df_resultados['Classificação Predita'].str.contains('DESFAVORÁVEL', case=False))
)
total = len(df_resultados)
accuracy = corretos / total if total > 0 else 0
# Precision/Recall para DESFAVORÁVEL
verdadeiros_positivos = sum(
(df_resultados['Classificação Real'].str.contains('Desfavorável', case=False) &
df_resultados['Classificação Predita'].str.contains('DESFAVORÁVEL', case=False))
)
falsos_positivos = sum(
(df_resultados['Classificação Real'].str.contains('Favorável', case=False) &
df_resultados['Classificação Predita'].str.contains('DESFAVORÁVEL', case=False))
)
falsos_negativos = sum(
(df_resultados['Classificação Real'].str.contains('Desfavorável', case=False) &
~df_resultados['Classificação Predita'].str.contains('DESFAVORÁVEL', case=False))
)
precision = verdadeiros_positivos / (verdadeiros_positivos + falsos_positivos) if (verdadeiros_positivos + falsos_positivos) > 0 else 0
recall = verdadeiros_positivos / (verdadeiros_positivos + falsos_negativos) if (verdadeiros_positivos + falsos_negativos) > 0 else 0
print("=" * 60)
print("📊 RESULTADOS DO ENSEMBLE")
print("=" * 60)
print(f"Accuracy: {accuracy:.1%}")
print(f"Precision (DESFAVORÁVEL): {precision:.1%}")
print(f"Recall (DESFAVORÁVEL): {recall:.1%}")
print(f"F1-Score: {2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0:.1%}")
print(f"\nCorretos: {corretos}/{total}")
print("\n" + "=" * 60)
print(df_resultados[['PL', 'Classificação Real', 'Classificação Predita', 'Score Final']].to_string(index=False))
return df_resultados
if __name__ == "__main__":
testar_ensemble()