anonymous12321's picture
Update src/streamlit_app.py
588a18c verified
import streamlit as st
import numpy as np
import re
from joblib import load
from transformers import AutoTokenizer, AutoModel
import torch
# ============================================================
# PAGE CONFIG
# ============================================================
st.set_page_config(
page_title="Council Topics Classifier",
page_icon="🏛️",
layout="wide",
initial_sidebar_state="expanded"
)
# ============================================================
# CUSTOM CSS
# ============================================================
st.markdown("""
<style>
/* ==============================
UNIVERSAL COLOR VARIABLES
============================== */
:root {
--accent-color: var(--primary-color);
--bg-color: var(--background-color);
--text-color: var(--text-color);
--secondary-bg: var(--secondary-background-color);
--border-color: var(--border-color);
}
/* ==============================
HEADER
============================== */
div[data-testid="stAppViewContainer"] .main-header {
font-size: 2.5rem !important;
font-weight: 900 !important;
color: var(--accent-color) !important;
text-align: center !important;
margin-bottom: 1rem !important;
letter-spacing: 0.5px;
}
/* ==============================
RESULT BOXES
============================== */
.result-box {
padding: 1rem;
margin: 0.5rem 0;
border-radius: 0.5rem;
border-left: 4px solid var(--accent-color);
background-color: var(--secondary-bg);
color: var(--text-color);
transition: background-color 0.2s ease, border-color 0.2s ease;
}
/* Add hover highlight */
.result-box:hover {
background-color: color-mix(in srgb, var(--secondary-bg) 85%, var(--accent-color) 15%);
}
/* ==============================
METRIC BOXES
============================== */
.metric-box {
background-color: var(--secondary-bg);
padding: 1rem;
border-radius: 0.75rem;
text-align: center;
color: var(--text-color);
border: 1px solid var(--border-color);
}
/* ==============================
LANGUAGE NOTICE
============================== */
.lang-notice {
background-color: color-mix(in srgb, #ffc107 15%, var(--bg-color) 85%);
border-left: 4px solid #ffc107;
padding: 1rem;
border-radius: 0.5rem;
margin: 1rem 0;
color: var(--text-color);
}
/* ==============================
STREAMLIT BASE ELEMENTS
============================== */
.stAlert, .stMarkdown, .stTextArea, .stMetric {
color: var(--text-color) !important;
}
/* Progress bar adaptation */
div[data-testid="stProgressBar"] > div > div {
background-color: var(--accent-color) !important;
}
</style>
""", unsafe_allow_html=True)
# ============================================================
# LOAD MODELS (with caching)
# ============================================================
@st.cache_resource(show_spinner="🔄 Loading models...")
def load_models():
"""Load all model components"""
models_dir = 'models'
# Load saved components
tfidf = load(f'{models_dir}/gradient_boosting_tfidf_vectorizer.joblib')
mlb = load(f'{models_dir}/gradient_boosting_mlb_encoder.joblib')
optimal_thresholds = np.load(f'{models_dir}/gradient_boosting_optimal_thresholds.npy')
adaptive_weights = np.load(f'{models_dir}/gradient_boosting_adaptive_weights.npy')
logistic_model = load(f'{models_dir}/gradient_boosting_logistic_model.joblib')
gb_models = load(f'{models_dir}/gradient_boosting_gb_models.joblib')
# Load BERT model
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
tokenizer = AutoTokenizer.from_pretrained("neuralmind/bert-base-portuguese-cased")
bert_model = AutoModel.from_pretrained("neuralmind/bert-base-portuguese-cased").to(device)
bert_model.eval()
return {
'tfidf': tfidf,
'mlb': mlb,
'optimal_thresholds': optimal_thresholds,
'adaptive_weights': adaptive_weights,
'logistic_model': logistic_model,
'gb_models': gb_models,
'tokenizer': tokenizer,
'bert_model': bert_model,
'device': device
}
models = load_models()
# ============================================================
# PREPROCESSING
# ============================================================
def smart_preprocess(text):
"""Smart preprocessing with Portuguese municipal context"""
text = text.lower()
substitutions = {
r'câmara\s+municipal': 'camara_municipal',
r'assembleia\s+municipal': 'assembleia_municipal',
r'junta\s+de\s+freguesia': 'junta_freguesia',
r'presidente\s+da\s+câmara': 'presidente_camara',
r'vereador\s+': 'vereador_',
r'art\.?\s*(\d+)': r'artigo_\1',
r'n\.?º\s*(\d+)': r'numero_\1',
r'decreto\s+lei': 'decreto_lei',
r'código\s+civil': 'codigo_civil',
r'€\s*(\d+)': r'\1_euros'
}
for pattern, replacement in substitutions.items():
text = re.sub(pattern, replacement, text)
text = re.sub(r'[^\w\s_]', ' ', text)
text = re.sub(r'\s+', ' ', text)
words = [w for w in text.split() if len(w) >= 2]
return ' '.join(words).strip()
# ============================================================
# FEATURE EXTRACTION
# ============================================================
def get_bert_embeddings(texts):
"""Get BERT embeddings"""
all_embeddings = []
with torch.no_grad():
for text in texts:
encoded = models['tokenizer']([text], padding=True, truncation=True,
max_length=512, return_tensors="pt").to(models['device'])
outputs = models['bert_model'](**encoded)
embeddings = outputs.last_hidden_state
attention_mask = encoded['attention_mask'].unsqueeze(-1)
embeddings = (embeddings * attention_mask).sum(1) / attention_mask.sum(1)
all_embeddings.append(embeddings.cpu().numpy())
return np.vstack(all_embeddings)
# ============================================================
# PREDICTION
# ============================================================
def predict_topics(text):
"""Predict topics using Gradient Boosting ensemble"""
# Preprocess
cleaned_text = smart_preprocess(text)
# Extract features
tfidf_features = models['tfidf'].transform([cleaned_text])
bert_features = get_bert_embeddings([cleaned_text])
# Combine features
X_dense = np.hstack([tfidf_features.toarray(), bert_features])
# Get predictions from LogisticRegression
logistic_proba = models['logistic_model'].predict_proba(X_dense)
# Get predictions from GradientBoosting models
gb_predictions = []
for gb_models_set in models['gb_models']:
gb_proba = np.zeros((1, len(models['mlb'].classes_)))
for label_idx, label_model in enumerate(gb_models_set):
gb_proba[:, label_idx] = label_model.predict_proba(X_dense)[:, 1]
gb_predictions.append(gb_proba)
# Weighted ensemble prediction
ensemble_proba = np.zeros_like(logistic_proba)
for label_idx in range(len(models['mlb'].classes_)):
w = models['adaptive_weights'][label_idx]
ensemble_proba[:, label_idx] = (
w[0] * logistic_proba[:, label_idx] +
w[1] * gb_predictions[0][:, label_idx] +
w[2] * gb_predictions[1][:, label_idx] +
w[3] * gb_predictions[2][:, label_idx]
)
# Apply optimal thresholds
predictions = np.zeros(len(models['mlb'].classes_))
for i, thresh in enumerate(models['optimal_thresholds']):
predictions[i] = (ensemble_proba[0, i] >= thresh).astype(int)
# --- Make sure at least one topic is given ---
if predictions.sum() == 0:
# No topic surpassed the threshold
best_idx = np.argmax(ensemble_proba[0])
predictions[best_idx] = 1
# Get predicted labels with confidences
results = []
for i, label in enumerate(models['mlb'].classes_):
if predictions[i] == 1:
confidence = ensemble_proba[0, i]
results.append((label, confidence))
# Sort by confidence
results.sort(key=lambda x: x[1], reverse=True)
return results, cleaned_text
# ============================================================
# MAIN APP
# ============================================================
def main():
# Header
st.markdown('<p class="main-header">🏛️ Council Topics Classifier</p>', unsafe_allow_html=True)
st.markdown("""
<p style="text-align: center; color: #666;">
Gradient Boosting + Active Learning Topic Classification for Portuguese municipal meeting minutes discussion subjects
</p>
""", unsafe_allow_html=True)
# Sidebar
st.sidebar.header("⚙️ Configuration")
example = st.sidebar.selectbox(
"Choose an example or enter your own text:",
[
"Custom Text",
"M01_cm_015_2024-06-19_sub05",
"M01_cm_021_2024-09-11_sub12",
"M02_cm_010_2024-05-15_sub17",
"M02_cm_020_2024-11-06_sub05",
"M03_cm_010_2024-06-17_sub16",
"M03_cm_021_2024-12-12_sub01",
"M04_cm_008_2024-13-05_sub012",
"M04_cm_010_2024-21-06_sub014",
"M05_cm_016_2024-09-30_sub16",
"M05_cm_018_2024-10-28_sub15",
"M06_cm_066_2024-10-14_sub10",
"M06_cm_070_2024-12-02_sub19"
]
)
# Example texts
example_texts = {
"Custom Text": "",
"M01_cm_015_2024-06-19_sub05": "Pelo Sr. Presidente foi presente a reunião a informação da contabilidade que se anexa à presente ata. \nPonderado e analisado o assunto o Executivo Municipal deliberou por maioria, com os votos a favor dos eleitos pelo PS e a abstenção da eleita pelo Nós, Cidadãos, ratificar a alteração orçamental permutativa.",
"M01_cm_021_2024-09-11_sub12": "Transporte de aluno para a Escola Associação Sociocultural Terapeuta de Évora.\n Pelo Senhor Presidente da Câmara Municipal foi presente a esta reunião um requerimento com o registo de entrada n.º 12022/24, de 02/09/2024, que se anexa à presente ata. Relativamente ao requerimento apresentado o Senhor Presidente da Câmara Municipal informou, que este era um transporte feito por parte da Autarquia á vários anos, e que, propunha que o mesmo fosse renovado nos mesmos moldes. \n Ponderado e analisado o assunto o Executivo Municipal deliberou por unanimidade, aprovar o transporte de um aluno para a Escola Associação Sociocultural Terapeuta de Évora. -",
"M02_cm_010_2024-05-15_sub17": "-ALTERAÇÃO DOS PREÇOS DE VENDA AO PÚBLICO DOS PRODUTOS PARA SEREM VENDIDO NOS ESPAÇOS TURÍSTICOS DE CAMPO MAIOR:\n-Apreciação da informação (registo 8274) do senhor presidente, referente ao assunto em epígrafe, que a seguir se transcreve:-“ Devido ao aumento dos valores dos fornecedores, é necessário proceder á alteração de preços de alguns dos produtos à venda nos espaços turísticos do Município, assim PROPONHO conforme determina o artigo 21º da Lei 73/2013, na sua atual redação, que os preços das peças/produtos para venda, sejam fixados pela Câmara Municipal, com IVA incluído à taxa legal em vigor, constantes do mapa abaixo mencionado:\n\n————————————Artigos para Venda————————————\nProdutos,IVA,Valor\nGarrafa de Azeite Virgem Extra Gralha 2L,6%,20,00€\nGarrafa de Azeite Virgem Extra Gralha 3L,6%,29,00€\nGarrafa de Azeite Virgem Extra GR 0,5L,6%,8,50€\n——————————————————————————————————————————— \n-A CÂMARA DELIBEROU, POR UNANIMIDADE, APROVAR A PROPOSTA DO SENHOR PRESIDENTE, REFERENTE À ALTERAÇÃO DOS PREÇOS DE VENDA AO PÚBLICO DOS PRODUTOS PARA SEREM VENDIDO NOS ESPAÇOS TURÍSTICOS DE CAMPO MAIOR, NOMEADAMENTE:\n\n————————————Artigo para venda————————————\nProdutos,IVA,Valor\nGarrafa de Azeite Virgem Extra Gralha 2L,6%,20,00€\nGarrafa de Azeite Virgem Extra Gralha 3L,6%,29,00€\nGarrafa de Azeite Virgem Extra GR 0,5L,6%,8,50€",
"M02_cm_020_2024-11-06_sub05": "-LOUVOR - PARA CONHECIMENTO:\n-Atribuição de um louvor ao Município de Campo Maior, na pessoa do Senhor Presidente, por parte da Associação Humanitária de Bombeiros Voluntários de Campo Maior, pelo apoio prestado ao longo do seu mandato, na aquisição de várias ambulâncias, cedência de pessoal e também financeiramente.\n-A CÂMARA TOMOU CONHECIMENTO.",
"M03_cm_010_2024-06-17_sub16": "11.2. – Empreitada da Obra de Requalificação e Construção de Parques Infantis nas Freguesias do Concelho\n\nPresente à Câmara informação da Divisão de Obras e Planeamento, constante da plataforma de gestão documental Sigmadoc Web/NIPG: 4341/24_Pendente: 100473, e conta final da Empreitada em apreço, onde se conclui poder ser aprovada pela Câmara Municipal.\n\nDocumentos que se dão como inteiramente reproduzidos na presente ata e ficam, para todos os efeitos legais, arquivados em pasta própria existente para o efeito.\n\nA Câmara deliberou, nos termos da informação da Divisão de Obras e do parecer do Senhor Diretor **************************************, aprovar a conta final da Empreitada da Obra de Requalificação e Construção de Parques Infantis nas Freguesias do Concelho.",
"M03_cm_021_2024-12-12_sub01": "Os serviços propuseram ao órgão a retirada do assunto contido na alínea a) do 5.2. - DFMA, tendo sido aprovado por unanimidade:\n\nRetirar:\n\n5.2. DEPARTAMENTO DE FINANÇAS E MODERNIZAÇÃO ADMINISTRATIVA\n\n“f) Atualização do Tarifário 2025 – Movicovilhã (Aprovação) ”",
"M04_cm_008_2024-13-05_sub012": "Foi apresentada à Câmara uma proposta subscrita pelo Senhor Presidente, datada de 2 de maio de 2024, e que se transcreve:\n“Considerando que o procedimento de concurso público “Unidade de Saúde Familiar - Cereja”, foi autorizado em sede de reunião de Câmara Municipal do Fundão, datada de 28/02/2024, e publicado na II Série do Diário da República, n.º 64 de 01/04/2024; Considerando que no âmbito do referido procedimento e devido a circunstâncias imprevistas é necessário alterar aspetos fundamentais das peças que o constituem; Considerando o teor do meu despacho datado de 2 de Maio de 2024, referente ao procedimento administrativo acima referido, e dada a necessidade premente ocorrida, Proponho, que a Câmara Municipal delibere no sentido de aprovar o despacho em anexo à presente proposta (anexo I), nos termos do n.º 3 do art.º 35.º da Lei 75/2013 de 12 de Setembro na sua atual redação.” A Câmara Municipal tomou conhecimento e deliberou, por unanimidade e em minuta, aprovar a proposta apresentada. (Empreitada de:“Unidade de saúde familiar – Cereja” –ratificação de despacho)",
"M04_cm_010_2024-21-06_sub014": "Foi apresentada à Câmara uma proposta subscrita pelo Senhor Vice-presidente, datada de 13 de maio de 2024, e que se transcreve:\n“Considerando o teor do despacho proferido no dia 29 de Maio de 2024, ora junto em anexo à presente proposta, relativo à aprovação das Normas de Participação e Funcionamento das Tascas e outros Espaços de Comercialização da Festa da Cereja, a realizar nos dias 07, 08, 09 e 10 de Junho de 2024, na freguesia de Alcongosta; Considerando o disposto no nº 3 do artigo 35.º da Lei n.º 75/2013, de 12 de Setembro, na sua atual redação, proponho, face aos factos e com os fundamentos que se deixam acima expostos, que a Câmara Municipal, delibere no sentido de ratificar o Despacho ora junto em anexo à presente proposta e que dela faz parte integrante.” A Câmara Municipal tomou conhecimento e deliberou, por unanimidade e em minuta, aprovar a proposta apresentada (Aprovação das “Normas de Participação e Funcionamento das Tascas e outros Espaços de Comercialização da Festa da Cereja – 2024” – ratificação de despacho)",
"M05_cm_016_2024-09-30_sub16": "12. TRANSPORTES – GRUPO DESPORTIVO OLIVEIRA DO CASTELO - CEDÊNCIA DE AUTOCARRO – Presente a seguinte proposta: “O Grupo Desportivo Oliveira do Castelo solicitou a colaboração do Município através da cedência de um autocarro, para efetuar o transporte da equipa de veteranos, no dia 19 de outubro, a Bragança. Considerando que se trata de uma deslocação que visa a participação da equipa num jogo/convívio a realizar com a equipa local, não se afigura inconveniente para os serviços a disponibilização do autocarro em causa, pelo que por despacho datado de 11 de setembro de 2024, foi deferido o pedido. Sendo uma competência da Câmara Municipal de Guimarães, a atribuição deste tipo de apoios, submete-se à aprovação do Executivo Camarário o transporte solicitado.” DELIBERADO APROVAR POR UNANIMIDADE.",
"M05_cm_018_2024-10-28_sub15": "14. TURISMO – PEDIDO DE RECONHECIMENTO DE INTERESSE PÚBLICO PARA A INSTALAÇÃO DE UM EMPREENDIMENTO DE AGROTURISMO – FREGUESIA DE NESPEREIRA – Presente, para aprovação pela Câmara Municipal e ulterior aprovação pela Assembleia Municipal, o reconhecimento de interesse público para a instalação de um empreendimento turístico, na modalidade de agroturismo, na rua da Arrochela de Cima, na freguesia de Nespereira, de acordo com documentos, que se dão aqui por reproduzidos e ficam arquivados em pasta anexa ao livro de atas. DELIBERADO, POR MAIORIA, SUBMETER À APROVAÇÃO DA ASSEMBLEIA MUNICIPAL. Votaram a favor o Presidente da Câmara e os Vereadores Adelina Paula Pinto, Paulo Lopes Silva, Paula Oliveira, Nelson Felgueiras, Sofia Ferreira, Ricardo Araújo, Hugo Ribeiro e Emília Lemos. Abstiveram-se as Vereadoras Ana Cotter e Vânia Dias da Silva.",
"M06_cm_066_2024-10-14_sub10": "10. Aprovação de apoio à Catorze de Outubro -- Suites & Events, Lda., na realização do FIOMS -- Festival Internacional de Órgão e Música Sacra, Edição 2024.\nAprovada, por unanimidade.",
"M06_cm_070_2024-12-02_sub19": "19. Aditamentos aos Contratos-Programa (i.) de Gestão de Resíduos Urbanos, (ii.) de Limpeza do Espaço Público, e (iii.) de Neutralidade Carbónica, a celebrar entre o Município do Porto e a Empresa Municipal de Ambiente do Porto, E. M., S. A.\nAprovada, por unanimidade.",
}
st.sidebar.markdown("---")
st.sidebar.markdown("### 📊 About")
st.sidebar.info("""
**Council Topics PT Classifier** uses Gradient Boosting with Active Learning to identify topics in Portuguese municipal documents.
- **Models**: LogReg + 3x GradientBoosting
- **Features**: TF-IDF + BERTimbau
- **Optimization**: Adaptive weighting
- **Language**: Portuguese
""")
st.sidebar.markdown("---")
st.sidebar.markdown("### ⚠️ Language Notice")
st.sidebar.warning("""
This model is trained on **Portuguese** texts.
Please enter text in Portuguese for accurate classification.
""")
# ============================================================
# COLLAPSIBLE SECTIONS
# ============================================================
# Example of how it works
with st.expander("💡 **Example: How the Classifier Works**", expanded=False):
st.markdown("""
#### Sample Classification Process
**Input Text:**
> "A Câmara Municipal aprovou o orçamento para 2024, com dotações para obras de reabilitação urbana no valor de 500.000€ e apoio financeiro a associações culturais."
**Expected Output:**
- 🟢 **Administração Geral, Finanças e Recursos Humanos** (Confidence: 84.2%)
- 🟡 **Obras Públicas** (Confidence: 67.7%)
- 🟡 **Cultura** (Confidence: 52.9%)
""")
# All possible topics
with st.expander("📋 **All Possible Topics** (Classification Categories)", expanded=False):
st.markdown("### Available Topics for Classification")
st.markdown("The model can identify the following categories:")
# Get all labels from the model
all_labels = sorted(models['mlb'].classes_)
num_cols = 4
cols = st.columns(num_cols)
for idx, label in enumerate(all_labels):
col_idx = idx % num_cols
with cols[col_idx]:
st.markdown(f"• **{label}**")
st.markdown(f"\n**Total: {len(all_labels)} topics**")
# Main content
col1, col2 = st.columns([1, 1])
with col1:
st.subheader("📝 Input Document")
if example == "Custom Text":
input_text = st.text_area(
"Enter your Portuguese text here:",
height=400,
placeholder="Paste your council discussion subject here...\n\nExample: A Câmara Municipal aprovou o orçamento de 2024 com investimentos em infraestruturas."
)
else:
input_text = st.text_area(
f"Example: {example}",
value=example_texts[example],
height=400
)
classify_button = st.button("🔍 Classify Topics", type="primary", use_container_width=True)
with col2:
st.subheader("📌 Identified Topics")
if classify_button and input_text:
if len(input_text.strip()) < 10:
st.warning("⚠️ Please enter a longer text (minimum 10 characters).")
else:
with st.spinner("🔄 Analyzing document..."):
# Perform classification
results, cleaned_text = predict_topics(input_text)
if not results:
st.info("ℹ️ No topics identified with sufficient confidence.")
else:
for label, confidence in results:
if confidence >= 0.75:
emoji = "🟢"
color = "#28a745"
elif confidence >= 0.50:
emoji = "🟡"
color = "#ffc107"
else:
emoji = "🟠"
color = "#fd7e14"
st.markdown(f"""
<div class="result-box">
<strong>{emoji} {label}</strong>
<br>
<span style="color: {color}; font-weight: bold;">Confidence: {confidence:.1%}</span>
</div>
""", unsafe_allow_html=True)
st.progress(confidence)
st.markdown("---")
# Add title for metrics section
st.markdown("### 📊 Classification Results Summary")
metric_col1, metric_col2, metric_col3 = st.columns(3)
with metric_col1:
st.markdown('<div class="metric-box">', unsafe_allow_html=True)
st.metric("Topics Found", len(results))
st.markdown('</div>', unsafe_allow_html=True)
with metric_col2:
avg_conf = np.mean([c for _, c in results])
st.markdown('<div class="metric-box">', unsafe_allow_html=True)
st.metric("Avg. Confidence", f"{avg_conf:.1%}")
st.markdown('</div>', unsafe_allow_html=True)
with metric_col3:
max_conf = max([c for _, c in results])
st.markdown('<div class="metric-box">', unsafe_allow_html=True)
st.metric("Max. Confidence", f"{max_conf:.1%}")
st.markdown('</div>', unsafe_allow_html=True)
st.markdown("---")
# Download button
result_text = "CLASSIFICATION RESULTS\n"
result_text += "=" * 50 + "\n\n"
for idx, (label, confidence) in enumerate(results, 1):
result_text += f"{idx}. {label}\n"
result_text += f" Confidence: {confidence:.2%}\n\n"
else:
st.info("👈 Enter text in the input box and click 'Classify Topics' to begin.")
# Show how it works
st.markdown("### 🎯 How It Works")
st.markdown("""
The classifier analyzes Portuguese municipal texts using:
1. **Feature Extraction**
- TF-IDF vectors (n-grams 1-3)
- BERTimbau contextual embeddings
2. **Ensemble Models**
- 1x LogisticRegression
- 3x GradientBoosting (different hyperparameters)
3. **Adaptive Weighting**
- Rare labels: Higher LogReg weight
- Common labels: Higher GB weight
4. **Dynamic Thresholds**
- Optimized per label on validation set
""")
if __name__ == "__main__":
main()