AgroScan / src /agroscan /classifier.py
DilliKel's picture
Adicionar classe AgroScanClassifier para diagnóstico e tratamento de culturas com embeddings
0fbc742
Raw
History Blame Contribute Delete
5.93 kB
from __future__ import annotations
from pathlib import Path
import unicodedata
from collections import Counter
from sentence_transformers import SentenceTransformer, util
from .data_loader import DataBundle, load_csv_data
DEFAULT_MODEL_NAME = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
class AgroScanClassifier:
def __init__(
self,
data: DataBundle,
model_name: str = DEFAULT_MODEL_NAME,
model: SentenceTransformer | None = None,
) -> None:
self.data = data
self.model = model or SentenceTransformer(model_name)
self.diagnosticos_base = (
self.data.df_base[self.data.diag_base_col].astype(str).tolist()
)
self.embeddings_diagnosticos = self.model.encode(
self.diagnosticos_base,
convert_to_tensor=True,
normalize_embeddings=True,
)
self.categorias_tratamento = (
self.data.df_culturas_pragas[self.data.diag_trat_col].astype(str).tolist()
)
self.embeddings_categorias = self.model.encode(
self.categorias_tratamento,
convert_to_tensor=True,
normalize_embeddings=True,
)
@classmethod
def from_csv(
cls,
base_csv_path: str | Path,
culturas_csv_path: str | Path,
model_name: str = DEFAULT_MODEL_NAME,
model: SentenceTransformer | None = None,
) -> "AgroScanClassifier":
data = load_csv_data(base_csv_path, culturas_csv_path)
return cls(data=data, model_name=model_name, model=model)
def diagnostico_e_tratamento(self, respostas: list[str]) -> dict[str, str]:
texto = " ".join([str(r).strip() for r in respostas if str(r).strip()])
if not texto:
return {"error": "Respostas vazias ou inválidas"}
# Prioriza match estruturado (linha da planilha) quando as respostas
# batem exatamente com os campos da base.
diagnostico = self._diagnostico_por_match_estruturado(respostas)
if diagnostico is None:
embedding_respostas = self.model.encode(
texto, convert_to_tensor=True, normalize_embeddings=True
)
similaridades = util.cos_sim(embedding_respostas, self.embeddings_diagnosticos)[0]
indice = int(similaridades.argmax().item())
diagnostico = self.diagnosticos_base[indice]
tratamento_df = self._buscar_tratamento(diagnostico)
if tratamento_df.empty:
return {
"diagnostico": diagnostico,
"tratamento_nivel_1": "Tratamento não encontrado",
"tratamento_nivel_2": "Tratamento não encontrado",
"tratamento_nivel_3": "Tratamento não encontrado",
}
tratamento = tratamento_df.iloc[0]
return {
"diagnostico": diagnostico,
"tratamento_nivel_1": str(tratamento[self.data.trat1_col]) if self.data.trat1_col else "Tratamento não encontrado",
"tratamento_nivel_2": str(tratamento[self.data.trat2_col]) if self.data.trat2_col else "Tratamento não encontrado",
"tratamento_nivel_3": str(tratamento[self.data.trat3_col]) if self.data.trat3_col else "Tratamento não encontrado",
}
def _diagnostico_por_match_estruturado(self, respostas: list[str]) -> str | None:
colunas_perguntas = [
col for col in self.data.df_base.columns if col != self.data.diag_base_col
]
# So aplica match estruturado quando ha respostas para todas as perguntas.
if len(respostas) < len(colunas_perguntas):
return None
respostas_norm = [self._normalizar_texto(r) for r in respostas[: len(colunas_perguntas)]]
if any(not r for r in respostas_norm):
return None
matches: list[str] = []
for _, row in self.data.df_base.iterrows():
valores_row = [self._normalizar_texto(row[col]) for col in colunas_perguntas]
if valores_row == respostas_norm:
matches.append(str(row[self.data.diag_base_col]))
if not matches:
return None
# Se houver duplicatas conflitantes, usa maioria e mantem comportamento deterministico.
return Counter(matches).most_common(1)[0][0]
def _buscar_tratamento(self, diagnostico: str):
# 1) Tenta match textual exato.
tratamento_df = self.data.df_culturas_pragas[
self.data.df_culturas_pragas[self.data.diag_trat_col].astype(str) == diagnostico
]
if not tratamento_df.empty:
return tratamento_df
# 2) Tenta match textual normalizado (acento, caixa e pontuação simples).
diagnostico_norm = self._normalizar_texto(diagnostico)
categoria_norm = self.data.df_culturas_pragas[self.data.diag_trat_col].astype(str).map(self._normalizar_texto)
tratamento_df = self.data.df_culturas_pragas[categoria_norm == diagnostico_norm]
if not tratamento_df.empty:
return tratamento_df
# 3) Fallback semântico para cobrir variações como "de" vs "do".
embedding_diag = self.model.encode(
diagnostico,
convert_to_tensor=True,
normalize_embeddings=True,
)
similaridades = util.cos_sim(embedding_diag, self.embeddings_categorias)[0]
indice = int(similaridades.argmax().item())
categoria_mais_proxima = self.categorias_tratamento[indice]
return self.data.df_culturas_pragas[
self.data.df_culturas_pragas[self.data.diag_trat_col].astype(str)
== categoria_mais_proxima
]
@staticmethod
def _normalizar_texto(texto: str) -> str:
texto = unicodedata.normalize("NFD", str(texto))
texto = "".join(ch for ch in texto if unicodedata.category(ch) != "Mn")
return " ".join(texto.lower().strip().split())