Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
OPENAI_API_KEY='sk-...'
1,104 changes: 1,078 additions & 26 deletions examples/uso_basico.ipynb

Large diffs are not rendered by default.

8 changes: 5 additions & 3 deletions pyproject.toml
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tava na minha lista de tarefas pensar em que versão de python vamos colocar como mínima. Imagino que trocou para 3.10 por conta do uso do list. O ideal é só identificar qual o menor valor que não quebra o nosso código, certo? Se sim, tudo certo.

Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ authors = [
description = "Uma biblioteca Python intuitiva para realizar clusterização de documentos textuais. Simplifica o processo desde a preparação dos dados e análise do número ideal de clusters (método do cotovelo) até a aplicação do algoritmo e exportação dos resultados. Ideal para agrupar grandes volumes de texto, como decisões judiciais, artigos ou comentários, de forma eficiente e com poucas linhas de código."
readme = "README.md"
license = { text = "MIT" }
requires-python = ">=3.8"
requires-python = ">=3.10"
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
Expand All @@ -29,8 +29,10 @@ dependencies = [
"matplotlib==3.10.1",
"scipy==1.15.2",
"openpyxl==3.1.5",
"pyarrow==19.0.1",
"tqdm" # Adicionado para barra de progresso
"pyarrow==19.0.1", # Adicionado para barra de progresso
"tqdm",
"openai>=1.75.0",
"python-dotenv>=1.1.0",
]

[project.urls]
Expand Down
78 changes: 76 additions & 2 deletions src/cluster_facil/cluster.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
validar_tipo_classificacao,
validar_opcao_salvar
)
from .utils_auto_label import gerar_rotulo_cluster, refinar_rotulos_clusters

logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')

Expand Down Expand Up @@ -255,7 +256,7 @@ def preparar(self, coluna_textos: str, limite_k: int = 10, n_init: str | int = '

logging.info(f"Analisando características de {len(df_para_preparar)} textos (TF-IDF)...")
# Define parâmetros padrão que podem ser sobrescritos pelos kwargs
default_tfidf_params = {'stop_words': STOPWORDS_PT}
default_tfidf_params = {'stop_words': list(STOPWORDS_PT)}
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tive também problema com isso e acabei solucionando trocando no arquivo utils como o STOPWORDS_PT está definido. Troquei de tupla pra lista e já fiz o push pra main. Acho que podemos deixar essa redundância. O que acha?

final_tfidf_kwargs = {**default_tfidf_params, **self._tfidf_kwargs}
logging.debug(f"Parâmetros finais para TfidfVectorizer: {final_tfidf_kwargs}") # Movido para DEBUG

Expand Down Expand Up @@ -594,7 +595,6 @@ def subcluster(self, classificacao_desejada: str) -> Self:
Raises:
KeyError: Se a coluna de classificação original não for encontrada.
ValueError: Se a `classificacao_desejada` não existir na coluna de classificação.
RuntimeError: Se `preparar` não foi chamado na instância original (necessário para saber qual era a coluna de texto).
"""
logging.info(f"Criando um novo objeto ClusterFacil para analisar o subcluster da classificação: '{classificacao_desejada}'")

Expand Down Expand Up @@ -680,3 +680,77 @@ def contar_classificacoes(self, inclui_na=False) -> pd.Series:
contagem = self.df[self.nome_coluna_classificacao].value_counts(dropna=inclui_na)
logging.info(f"Contagem de textos por classificação manual na coluna '{self.nome_coluna_classificacao}':\n{contagem}")
return None

def auto_label_cluster(self, rodada: int = None, model: str = "gpt-4.1-nano", api_key: str = None, temperature: float = 0.0, cut_limit: int = 30, random_state: int = None, final_refine: bool = True, n_examples_final: int = 10) -> dict:
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

O random_state padrão tá como 42 em outros lugares. Acho que vale padronizar em um só valor.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Considerando que esse é um método de um objeto da classe ClusterFacil, acho que não precisa incluir "cluster" no nome.
Podemos também abrasileirar para "auto_classificar" ou algo do tipo.

"""
Gera rótulos automáticos para todos os clusters de uma rodada e adiciona uma coluna com esses rótulos ao DataFrame.
Após a rotulação inicial, faz uma passada final no LLM para sugerir nomes finais (unificando ou ajustando rótulos se necessário).

Args:
rodada (int, opcional): Número da rodada de clusterização (se None, usa a última rodada).
model (str): Nome do modelo OpenAI (default: 'gpt-4.1-nano').
api_key (str, opcional): Chave da API OpenAI. Se não fornecida, busca em OPENAI_API_KEY.
temperature (float): Temperatura do modelo.
cut_limit (int, opcional): Número máximo de textos a serem enviados para o LLM por cluster. Se None, usa todos. Default=30.
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acho que o default pode ser menor para economizar tokens. Estamos usando 10 para analisar manualmente e tem funcionado bem.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Estou me referindo ao cut_limit

random_state (int, opcional): Semente para amostragem aleatória dos textos. Default=None (não controla aleatoriedade).
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mencionei acima a sugestão de padronizar em 42.
Numa nota mais de desenho da biblioteca, acho que o ideal seria controlar todas as aleatoriedades possíveis para tornar os resultados de cada pesquisador reprodutíveis.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

E aqui ao random_state

final_refine (bool): Se True, faz uma passada final no LLM para refinar/unificar rótulos. Default=True.
n_examples_final (int): Número de exemplos de texto por cluster enviados na revisão final. Default=10.
Returns:
dict: Dicionário mapeando cluster_id para rótulo final gerado.
"""
import json
if rodada is None:
rodada = self.rodada_clusterizacao - 1
coluna_cluster = f"{self.prefixo_cluster}{rodada}"
if coluna_cluster not in self.df.columns:
raise ValueError(f"Coluna de cluster '{coluna_cluster}' não encontrada no DataFrame.")
cluster_ids = self.df[coluna_cluster].dropna().unique()
cluster_labels = {}
cluster_samples = {}
for cluster_id in cluster_ids:
textos_do_cluster = self.df.loc[self.df[coluna_cluster] == cluster_id, self.coluna_textos].astype(str).tolist()
if not textos_do_cluster:
cluster_labels[cluster_id] = None
continue
# Amostragem se necessário
if cut_limit is not None and len(textos_do_cluster) > cut_limit:
import random
rnd = random.Random(random_state) if random_state is not None else random
textos_amostrados = rnd.sample(textos_do_cluster, cut_limit)
else:
textos_amostrados = textos_do_cluster
Comment on lines +716 to +721
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acho que podemos refatorar para que tanto essa função quanto a que gera as amostras ao salvar utilize a mesma lógica.

Inclusive, acrescentando a função de auto_label, talvez faça mais sentido mudar a lógica padrão do método salvar.

label = gerar_rotulo_cluster(
textos_amostrados,
model=model,
api_key=api_key,
temperature=temperature
)
cluster_labels[cluster_id] = label
# Amostras para revisão final
if n_examples_final is not None and len(textos_do_cluster) > n_examples_final:
import random
rnd = random.Random(random_state) if random_state is not None else random
examples = rnd.sample(textos_do_cluster, n_examples_final)
else:
examples = textos_do_cluster[:n_examples_final]
cluster_samples[cluster_id] = {"label": label, "examples": examples}
coluna_rotulo = f"{coluna_cluster}_classificacao"
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acho que vale pensarmos em como entendemos que deve ser a relação entre o auto_label e a classificação manual.

Inicialmente, pensei que idealmente o nome da coluna automatica deveria ser pelo menos ligeiramente diferente da coluna classificada manualmente, pra facilitar a identificação de como o cluster foi gerado apenas olhando pra planilha.
No entanto, isso dificultaria que fosse misturado o uso de classificação manual e classificação automática. Nesse sentido, vale pensarmos se não seria o caso de desenhar esse método de modo que a conduta padrão do auto_label seja classificar apenas o que não foi ainda classificado manualmente, se algo já foi classificado, com um parametro para forçar a reclassificação completa.

self.df[coluna_rotulo] = self.df[coluna_cluster].map(cluster_labels)

# Passada final de refinamento global
if final_refine:
refined_labels_struct = refinar_rotulos_clusters(
cluster_samples=cluster_samples,
model=model,
api_key=api_key,
temperature=temperature
)
# Novo formato: {'clusters': [{'cluster_id': int, 'label': str}, ...]}
if isinstance(refined_labels_struct, dict) and "clusters" in refined_labels_struct:
refined_labels = {item["cluster_id"]: item["label"] for item in refined_labels_struct["clusters"]}
else:
refined_labels = refined_labels_struct
# Atualiza coluna de rótulos finais
self.df[coluna_rotulo] = self.df[coluna_cluster].map(refined_labels)
return refined_labels
return cluster_labels
145 changes: 145 additions & 0 deletions src/cluster_facil/utils_auto_label.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
"""
Funções utilitárias para rotulação automática de clusters usando modelos de linguagem (ex: GPT-4.1-nano via OpenAI API).
"""
import os
from typing import List, Optional, Dict
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Vale pensar se vamos padronizar em usar esses tipos ou os que foram incluídos nativamente no python

from openai import OpenAI
import json

def gerar_rotulo_cluster(
cluster_texts: List[str],
model: str = "gpt-4.1-nano",
api_key: Optional[str] = None,
temperature: float = 0.0,
) -> str:
"""
Gera um rótulo (nome/tema) para um cluster de textos usando a OpenAI Responses API.
Args:
cluster_texts (List[str]): Lista de textos pertencentes ao cluster.
model (str): Nome do modelo OpenAI a ser utilizado. Padrão: 'gpt-4.1-nano'.
api_key (Optional[str]): Chave da API OpenAI. Se não fornecida, busca em OPENAI_API_KEY.
temperature (float): Temperatura do modelo (criatividade).
Returns:
str: Rótulo sugerido para o cluster.
"""
if not api_key:
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
raise ValueError("É necessário fornecer uma chave de API OpenAI via argumento ou variável de ambiente OPENAI_API_KEY.")

client_kwargs = {"api_key": api_key}
client = OpenAI(**client_kwargs)

sample_texts = cluster_texts[:10]
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Por que o padrão é 30, mas aqui tá hard coded que vão ser utilizados apenas 10?

# Delimita cada texto com <sampleN>...</sampleN>
joined_texts = "\n\n".join(f"<sample{i+1}>\n{t}\n</sample{i+1}>" for i, t in enumerate(sample_texts))
prompt = (
"Dado o seguinte conjunto de textos, gere um rótulo curto (tema) que represente o cluster. "
"O rótulo deve ser claro, conciso e descritivo."
)
Comment on lines +36 to +39
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
prompt = (
"Dado o seguinte conjunto de textos, gere um rótulo curto (tema) que represente o cluster. "
"O rótulo deve ser claro, conciso e descritivo."
)
prompt = (
"Dado o seguinte conjunto de textos, gere um rótulo curto (tema) que represente o cluster. "
"O rótulo deve ser claro, conciso e descritivo.\n"
"Se não for possível identificar um padrão claro entre as decisões, responda "falta_coesão".
)

Uma parte importante das rodadas de clusterização é entender quais clusters estão coesos e quais não estão. Acho que precisamos indicar isso explicitamente como uma possibilidade, para que esses casos possam ser novamente clusterizados.

Nesse sentido, seria interessante eventualmente incluir a possibilidade de que uma segunda rodada de auto_label seja automaticamente aplicada apenas nos casos considerados não coesos. E, a médio prazo, poderiamos automatizar seguidas rodadas de reclusterização.

Ligado a isso, registro aqui algum ceticismo em relação a se modelos menores vão ser capazes de fazer essa decisão de falta de unidade. Acho que os modelos menores vão se sentir mais pressionados a dizer que há algo em comum, pelo que já tive de experiência em outros casos.

# Utiliza a Responses API mais recente com formato estruturado
resp_format = {
"format": {
"type": "json_schema",
"name": "rotulo",
"schema": {
"type": "object",
"properties": {
"rotulo": {
"type": "string",
"description": "Rótulo curto, claro e descritivo para o cluster de textos."
}
},
"required": ["rotulo"],
"additionalProperties": False
},
"strict": True
}
}
Comment on lines +40 to +58
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Langchain nos ajudaria também a garantir que todos os provedores nos forneçam respostas parseadas em json

response = client.responses.create(
model=model,
instructions=prompt,
temperature=temperature,
input=f"Textos do cluster:\n{joined_texts}\n\nRótulo:",
text=resp_format
)
label_obj = json.loads(response.output_text)
return label_obj["rotulo"]

def refinar_rotulos_clusters(
cluster_samples: Dict,
model: str = "gpt-4.1-nano",
api_key: str = None,
temperature: float = 0.0,
) -> Dict:
"""
Usa o LLM para revisar, padronizar e possivelmente agrupar rótulos de clusters, a partir de amostras e rótulos iniciais.
Args:
cluster_samples (dict): Dict {cluster_id: {"label": rótulo, "examples": [str, ...]}}
model (str): Nome do modelo OpenAI.
api_key (str): Chave da API OpenAI.
temperature (float): Temperatura do modelo.
Returns:
dict: Dicionário {cluster_id: rótulo_final}
"""
if not api_key:
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
raise ValueError("É necessário fornecer uma chave de API OpenAI via argumento ou variável de ambiente OPENAI_API_KEY.")

Comment on lines +85 to +89
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Se isso vai se repetir, acho que, na hora de generalizar para poder receber outras APIs, podemos criar uma função que lida com as chaves e a armazena junto ao cluster_facil.

client_kwargs = {"api_key": api_key}
client = OpenAI(**client_kwargs)

# Monta prompt e input estruturado
prompt = (
"Você receberá exemplos de clusters, cada um com um rótulo sugerido e algumas amostras de textos.\n"
"Sua tarefa é:\n"
"- Unificar rótulos semelhantes se fizer sentido,\n"
"- Sugerir nomes mais claros e concisos para cada grupo,\n"
"- Retornar um dicionário JSON com o id do cluster e o novo rótulo.\n\n"
"Exemplo de entrada:\n"
"Cluster 0 - Rótulo inicial: 'Esportes'\n<sample1>Texto exemplo</sample1>\n<sample2>Texto exemplo</sample2>\n\n"
"Cluster 1 - Rótulo inicial: 'Futebol'\n<sample1>Texto exemplo</sample1>\n<sample2>Texto exemplo</sample2>\n\n"
"Agora, siga o mesmo padrão para os clusters abaixo:\n"
)
Comment on lines +94 to +104
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tenho a impressão de que para essa revisão um llm de raciocínio funcionaria melhor. Podemos testar ter como padrão o o4-mini-low.

clusters_str = ""
for cid, info in cluster_samples.items():
label = info["label"]
examples = "\n\n".join(f"<sample{i+1}>\n{t}\n</sample{i+1}>" for i, t in enumerate(info["examples"]))
clusters_str += f"Cluster {cid} - Rótulo inicial: '{label}'\n{examples}\n\n"
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Eu me sentiria confortável até em reduzir para metade os clusters que aparecem aqui.

Acho que vale incluir também algum tipo de conferência de tamanho dos tokens nessa chamada, para evitar passar do limite em casos limite.

input_text = clusters_str + "Retorne APENAS um JSON no formato: {\"cluster_id\": \"novo rótulo\", ...}"
resp_format = {
"format": {
"type": "json_schema",
"name": "refino_rotulos",
"schema": {
"type": "object",
"properties": {
"clusters": {
"type": "array",
"items": {
"type": "object",
"properties": {
"cluster_id": {"type": "integer"},
"label": {"type": "string"}
},
"required": ["cluster_id", "label"],
"additionalProperties": False
}
}
},
"required": ["clusters"],
"additionalProperties": False
},
"strict": True
}
}
response = client.responses.create(
model=model,
instructions=prompt,
temperature=temperature,
input=input_text,
text=resp_format
)
refined_labels = json.loads(response.output_text)
return refined_labels
Loading