-
Notifications
You must be signed in to change notification settings - Fork 0
Feat/embedding #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
772475e
b2734cf
08bdab6
477dd99
7e84566
68bb200
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| OPENAI_API_KEY='sk-...' |
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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') | ||
|
|
||
|
|
@@ -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)} | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
|
||
|
|
@@ -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}'") | ||
|
|
||
|
|
@@ -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: | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
| """ | ||
| 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. | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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). | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Mencionei acima a sugestão de padronizar em 42.
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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" | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
| 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 | ||
| 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 | ||||||||||||||||||||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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] | ||||||||||||||||||||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
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
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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" | ||||||||||||||||||||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||||||||||||||||||||
There was a problem hiding this comment.
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.