Clasificador de intención NLP para arquitectura multiagente geoespacial
Python 3.12 · uv · scikit-learn · spaCy · FastAPI · Docker
Oldrin Santiago Bonilla Cáceres
Maestrante en Ciencia de Datos
Curso: Machine Learning (Aprendizaje de Máquina) — 2026
- Descripción del problema
- Arquitectura
- Integración con ArcGIS
- Decisiones técnicas
- Estructura del proyecto
- Flujo de trabajo completo
- Instalación y comandos
- Pipeline de datos
- Modelos y justificación
- Resultados
- API
- Frontend
- Docker
- Tests
- Notebook
- Referencias
En arquitecturas multiagente modernas, el sistema debe interpretar instrucciones del usuario en lenguaje natural y redirigirlas al agente especializado correspondiente. Sin un clasificador de intención, el sistema no sabe a qué agente enviar cada instrucción.
Un clasificador de texto que, dada una instrucción en español, identifica automáticamente la intención geoespacial y la dirige al agente GIS correspondiente.
| Intent | Descripción | Ejemplo |
|---|---|---|
query_layer |
Consultar capas disponibles | "muéstrame las capas del mapa" |
spatial_filter |
Filtrar por geometría/ubicación | "filtra los polígonos del área" |
calculate_area |
Calcular áreas y medidas | "calcula el área de los parques" |
get_attributes |
Consultar atributos de capa | "qué campos tiene la capa de ríos" |
export_data |
Exportar datos | "exporta los resultados a shapefile" |
visualize_map |
Cambiar simbología del mapa | "muestra el mapa por categoría" |
spatial_join |
Unir capas espacialmente | "cruza las capas por intersección" |
buffer_analysis |
Crear zonas de influencia | "crea un buffer de 500m" |
Usuario (texto o voz)
│
▼
┌───────────────────┐
│ Frontend │ ← visor web con Web Speech API (Chrome/Edge)
│ index.html │ HTML + CSS + JS puro, sin framework
└────────┬──────────┘
│ HTTP POST /predict
▼
┌─────────────────────────────┐
│ FastAPI — src/api/ │
│ POST /predict │ ← preprocesa texto + clasifica
│ GET /health │ ← estado de los modelos
│ WS /speech │ ← clasificación por voz en tiempo real
└────────┬────────────────────┘
│ preprocess_text() → spaCy
│ predict() → model.pkl
▼
┌─────────────────────────────────────┐
│ Clasificador de Intención │
│ │
│ Model01: TF-IDF + chi² + SVM │ ← F1-macro: 0.96 (texto preprocesado)
│ Model02: TF-IDF + chi² + MLP │ ← F1-macro: 0.88 (más robusto en producción)
└────────┬────────────────────────────┘
│
▼
Agente Geoespacial (geo_agent)
Recibe el intent clasificado y ejecuta la operación GIS correspondiente
El clasificador fue diseñado para integrarse en una arquitectura multiagente con ArcGIS. El intent detectado puede usarse directamente para ejecutar operaciones GIS mediante arcpy o la ArcGIS API for Python.
Usuario (voz o texto)
↓
Geo-Intent Classifier ← este proyecto
↓
geo_agent (ArcGIS)
↓
┌───────────────────────────────────────────────────────────────────┐
│ query_layer → ArcGIS REST API (listar feature layers) │
│ spatial_filter → SelectLayerByLocation / SelectLayerByAttribute │
│ calculate_area → CalculateGeometryAttributes │
│ get_attributes → Describe / ListFields │
│ export_data → FeatureClassToFeatureClass / CopyFeatures │
│ visualize_map → ApplySymbology / UpdateLayer │
│ spatial_join → SpatialJoin │
│ buffer_analysis → Buffer / MultipleRingBuffer │
└───────────────────────────────────────────────────────────────────┘
ArcGIS Pro tiene un entorno Python propio. El modelo puede cargarse directamente dentro de un script de geoprocesamiento:
import arcpy
import joblib
model = joblib.load("models/model02.pkl")
le = joblib.load("models/label_encoder.pkl")
def clasificar_instruccion(texto: str) -> str:
idx = model.predict([texto])[0]
return le.inverse_transform([idx])[0]
# El usuario escribe → clasifica → ejecuta la operación GIS
intent = clasificar_instruccion("calcula el área de los parques")
if intent == "calculate_area":
arcpy.CalculateGeometryAttributes_management(...)
elif intent == "spatial_filter":
arcpy.SelectLayerByLocation_management(...)
elif intent == "buffer_analysis":
arcpy.Buffer_analysis(...)
elif intent == "export_data":
arcpy.FeatureClassToFeatureClass_conversion(...)La API REST que ya está construida puede consumirse desde cualquier notebook de ArcGIS Online o ArcGIS Notebooks:
import requests
from arcgis.gis import GIS
from arcgis.geometry import buffer
gis = GIS("https://www.arcgis.com", "usuario", "contraseña")
def geo_agent(instruccion: str):
# 1. Clasificar intent via API
response = requests.post(
"http://tu-servidor:8000/predict",
json={"text": instruccion, "model": "model02"}
)
intent = response.json()["intent"]
confidence = response.json()["confidence"]
print(f"Intent: {intent} ({confidence:.0%} confianza)")
# 2. Ejecutar operación según intent
if intent == "buffer_analysis":
return buffer(feature_layer, distance=500, unit="meters")
elif intent == "spatial_join":
return arcgis.features.SpatialJoin(target, join_layer)
elif intent == "export_data":
feature_layer.export_to_csv("output.csv")
# ...
# Uso
geo_agent("crea un buffer de 500 metros alrededor de las escuelas")
# → Intent: buffer_analysis (97% confianza)
# → Ejecuta Buffer_analysis en ArcGISEl frontend (index.html + app.js) puede embeberse como widget personalizado en Experience Builder, conectándose a la API FastAPI para clasificar instrucciones y ejecutar operaciones en el mapa web en tiempo real.
- ✅ Clasificador entrenado y deployado como API REST
- ✅ WebSocket para voz en tiempo real
- ✅ Docker para desplegar en servidor accesible desde ArcGIS Online
- ✅ Endpoint
/predictque devuelve el intent en formato JSON consumible por cualquier cliente - ⬅ Pendiente: implementar el agente GIS que recibe el intent y ejecuta
arcpy/ ArcGIS API
TF-IDF (Term Frequency — Inverse Document Frequency) es el método que convierte texto en números que el modelo puede procesar. Asigna un peso numérico a cada palabra según dos criterios:
- TF (Term Frequency): qué tan frecuente es la palabra en ese documento
- IDF (Inverse Document Frequency): qué tan rara es la palabra en todo el corpus
Palabra "buffer" → aparece mucho en buffer_analysis, poco en el resto → peso ALTO
Palabra "de" → aparece en todos los documentos por igual → peso BAJO
El resultado es una matriz numérica donde cada fila es un utterance y cada columna es un término del vocabulario. Los modelos ML trabajan sobre esta matriz, no sobre el texto directamente.
Con ngram_range=(1,2) también captura bigramas — pares de palabras consecutivas:
"calcular área" → una sola feature que distingue calculate_area de otros intents
"exportar shapefile" → feature única de export_data
uv es un gestor de paquetes moderno escrito en Rust que reemplaza a pip + venv. Se eligió por tres razones concretas:
- Velocidad: instala dependencias hasta 10-100x más rápido que pip gracias a su resolver en Rust y caché agresiva
- Reproducibilidad: genera un
uv.lockcon hashes exactos de cada paquete, garantizando que cualquier persona que clone el repo obtenga exactamente el mismo entorno - Estándar moderno: usa
pyproject.tomlcomo única fuente de verdad, eliminandorequirements.txty sus problemas de inconsistencia
# Con pip (antiguo)
pip install -r requirements.txt # puede instalar versiones diferentes cada vez
# Con uv (moderno)
uv sync # instala exactamente lo del uv.lock, siempre igualDocker se usa para garantizar que el proyecto funcione igual en cualquier entorno — laptop, servidor, CI/CD. Sin Docker:
- En tu máquina funciona, en la del profesor no porque tiene Python 3.11
- Las versiones de spaCy o sklearn pueden diferir entre sistemas
- El modelo
.pklpuede no ser compatible entre versiones
Con Docker:
docker compose up --build
# → Python 3.12 exacto + todas las deps + modelos + API en localhost:8000
# → Funciona igual en Windows, macOS y LinuxEl Dockerfile usa multistage build para mantener la imagen pequeña:
- Stage builder: instala uv y todas las dependencias
- Stage runtime: copia solo lo necesario, sin herramientas de build
project-machine-learning/
│
├── .github/workflows/
│ ├── ci.yml # Corre lint (ruff) + tests (pytest) en cada push a GitHub
│ └── train.yml # Pipeline de entrenamiento automático en CI
│
├── data/ # Datos en diferentes etapas del pipeline
│ ├── raw/
│ │ └── intents_raw.jsonl # Utterances crudos generados por GROQ + Mistral
│ ├── interim/
│ │ └── intents_clean.jsonl # Corpus limpio: lematizado, sin stopwords, sin números
│ ├── processed/
│ │ ├── X_train.pkl # Array de textos para entrenar (80%)
│ │ ├── X_test.pkl # Array de textos para evaluar (20%)
│ │ ├── y_train.pkl # Labels codificados como enteros para entrenar
│ │ └── y_test.pkl # Labels codificados como enteros para evaluar
│ └── external/ # Recursos externos (stopwords, vocabularios)
│
├── docs/ # Documentación del proyecto (mkdocs)
│
├── notebooks/
│ └── main.ipynb # Notebook principal: EDA → features → modelos → resultados
│
├── reports/
│ └── figures/ # Gráficas generadas: curvas de aprendizaje, t-SNE, matrices
│
├── src/ # Código fuente modular del proyecto
│ ├── config.py # Centraliza TODOS los paths, constantes e intents
│ │
│ ├── data/
│ │ ├── generator.py # Genera utterances usando GROQ API + Mistral (Ollama)
│ │ ├── preprocess.py # Limpia: numeración → minúsculas → regex → spaCy
│ │ └── dataset.py # Split estratificado 80/20 y guarda los .pkl
│ │
│ ├── features/
│ │ ├── extraction.py # BoW, TF-IDF y TF-IDF+ngrams para comparar en notebook
│ │ └── selection.py # chi², mutual info y variance threshold (métodos filter)
│ │
│ ├── models/
│ │ ├── model01/ # Pipeline baseline: TF-IDF + chi² + SVM/NB
│ │ │ ├── model01.py # Define build_svm_pipeline() y build_nb_pipeline()
│ │ │ ├── train.py # GridSearchCV + RepeatedKFold(10×10) → model01.pkl
│ │ │ ├── predict.py # predict(text) → {intent, confidence, agent}
│ │ │ └── dataloader.py
│ │ └── model02/ # Pipeline avanzado: TF-IDF(word+char) + chi² + MLP
│ │ ├── model02.py # Define build_mlp_pipeline() con FeatureUnion
│ │ ├── train.py # GridSearchCV + RepeatedKFold(10×10) → model02.pkl
│ │ ├── predict.py # predict(text) → {intent, confidence, agent}
│ │ └── dataloader.py
│ │
│ ├── api/
│ │ ├── main.py # FastAPI: carga modelos al iniciar + sirve frontend
│ │ ├── routes.py # POST /predict (con preprocesamiento spaCy) + /health + WS
│ │ ├── schemas.py # Pydantic: IntentRequest, IntentResponse, HealthResponse
│ │ └── middleware.py # CORS + logging de requests
│ │
│ └── speech/
│ ├── stt.py # Speech-to-Text
│ └── tts.py # Text-to-Speech
│
├── frontend/ # Visor demo — HTML/CSS/JS puro
│ ├── index.html
│ ├── app.js # Web Speech API + fetch /predict + render resultado
│ └── css/style.css
│
├── models/ # Modelos y resultados de entrenamiento
│ ├── model01.pkl
│ ├── model02.pkl
│ ├── label_encoder.pkl
│ ├── cv_results_model01.csv
│ ├── cv_results_model02.csv
│ ├── cv_results_nb.csv
│ ├── model01_summary.csv
│ ├── model02_summary.csv
│ └── best_model01_name.txt
│
├── tests/
│ ├── conftest.py
│ ├── test_data.py
│ ├── test_features.py
│ ├── test_models.py
│ └── test_api.py
│
├── .env.example
├── .gitignore
├── CONTRIBUTING.md
├── Dockerfile
├── docker-compose.yml
├── LICENSE
├── Makefile
├── pyproject.toml
├── README.md
└── uv.lock
uv run python -m src.data.generatorLlama a GROQ API (LLaMA 3.3 70B) + Mistral local (Ollama) para generar ~1146 utterances.
Resultado: data/raw/intents_raw.jsonl
uv run python -m src.data.preprocessAplica: numeración → minúsculas → regex → lematización spaCy → stopwords.
Resultado: data/interim/intents_clean.jsonl
uv run python -m src.data.datasetLabelEncoder + split estratificado 80/20.
Resultado: data/processed/*.pkl + models/label_encoder.pkl
uv run python -m src.models.model01.train
uv run python -m src.models.model02.trainGridSearchCV + RepeatedKFold(10×10) para ambos modelos.
Resultado: models/model01.pkl, models/model02.pkl, CSVs de resultados
uv run uvicorn src.api.main:app --reload --port 8000Disponible en http://localhost:8000
¿Por qué el preprocesamiento en la API? Los modelos fueron entrenados con texto preprocesado. Si la API enviara texto crudo habría inconsistencia entre entrenamiento y producción. El mismo pipeline spaCy se aplica en
/predictantes de clasificar, garantizando que el F1 reportado en el notebook sea válido en producción.
- Python 3.12
- uv
- GROQ API Key — console.groq.com
- Ollama (opcional) — ollama.com
# Clonar
git clone https://github.com/tu-usuario/project-machine-learning.git
cd project-machine-learning
# Entorno virtual
uv venv .venv --python 3.12
# Activar (Windows)
.venv\Scripts\activate
# Activar (Linux/macOS)
source .venv/bin/activate
# Instalar dependencias
uv sync
# Dependencias de desarrollo (tests, linting)
uv sync --extra dev
# Variables de entorno
cp .env.example .env
# Editar .env: agregar GROQ_API_KEY=gsk_...
# Modelo spaCy (una sola vez)
uv run python -m spacy download es_core_news_sm# ── Pipeline completo de datos ──────────────────────────
uv run python -m src.data.generator # 1. Generar corpus con GROQ + Mistral
uv run python -m src.data.preprocess # 2. Limpiar y lematizar con spaCy
uv run python -m src.data.dataset # 3. Split 80/20 y guardar .pkl
# ── Entrenamiento ───────────────────────────────────────
uv run python -m src.models.model01.train # Entrenar Model01 (SVM)
uv run python -m src.models.model02.train # Entrenar Model02 (MLP)
# ── API ─────────────────────────────────────────────────
uv run uvicorn src.api.main:app --reload --port 8000
# ── Tests ───────────────────────────────────────────────
uv run pytest # Todos los tests
uv run pytest --cov=src # Con cobertura
# ── Calidad de código ───────────────────────────────────
uv run ruff check src/ # Linting
uv run ruff format src/ # Formatear
# ── Docker ──────────────────────────────────────────────
docker compose up --build # Producción
docker compose --profile dev up --build # Desarrollo (hot-reload)
docker compose down # Detener
# ── Makefile (atajos) ───────────────────────────────────
make install # setup completo
make data # genera + preprocesa + splitea
make train # entrena ambos modelos
make api # levanta la API
make test # corre tests
make docker # build y levanta Docker
make clean # limpia cachesGROQ API (LLaMA 3.3 70B) + Mistral local (Ollama)
│ src/data/generator.py
▼
data/raw/intents_raw.jsonl ← ~1146 utterances crudos
│ src/data/preprocess.py (spaCy es_core_news_sm)
▼
data/interim/intents_clean.jsonl ← lematizados, sin stopwords
│ src/data/dataset.py
▼
data/processed/X_train.pkl ← 80% textos para entrenar
data/processed/X_test.pkl ← 20% textos para evaluar
data/processed/y_train.pkl ← labels enteros train
data/processed/y_test.pkl ← labels enteros test
│ src/models/model0N/train.py
│ Pipeline: TF-IDF → chi² → clasificador
▼
models/model01.pkl ← pipeline SVM serializado
models/model02.pkl ← pipeline MLP serializado
Se implementan dos modelos con enfoques distintos para comparar estadísticamente su rendimiento (test de Wilcoxon) y elegir el mejor para producción.
¿Por qué SVM para clasificación de texto?
El SVM (Support Vector Machine) es un algoritmo de aprendizaje supervisado — necesita las etiquetas de intent durante el entrenamiento para aprender a separar las clases. Es el modelo clásico más efectivo para clasificación de texto por tres razones:
- Funciona bien en alta dimensionalidad: el espacio TF-IDF puede tener miles de features — SVM los maneja eficientemente
- Margen máximo: encuentra el hiperplano que maximiza la separación entre clases, siendo robusto con pocos datos
- Velocidad:
LinearSVCes extremadamente rápido comparado con kernels no lineales o redes neuronales
¿Por qué también Naive Bayes?
Se incluye Naive Bayes como alternativa para seleccionar el mejor mediante comparación directa. NB es el baseline tradicional para clasificación de texto — si SVM no lo supera, no justifica su complejidad adicional.
Pipeline([
('tfidf', TfidfVectorizer(max_features=3000, ngram_range=(1,2), sublinear_tf=True)),
('chi2', SelectKBest(chi2, k=1000)),
('clf', CalibratedClassifierCV(LinearSVC(C=1.0), cv=3)),
])| Componente | Por qué se usa |
|---|---|
TfidfVectorizer |
Convierte texto a vector numérico ponderando términos informativos. sublinear_tf=True aplica log(tf) reduciendo el peso de términos muy repetidos |
ngram_range=(1,2) |
Captura bigramas como "calcular área" que son más discriminativos que palabras sueltas |
SelectKBest(chi2) |
Filtra los términos estadísticamente más dependientes de la clase — reduce ruido antes del clasificador |
LinearSVC |
SVM lineal optimizado para alta dimensionalidad — rápido y preciso en texto |
CalibratedClassifierCV |
LinearSVC no tiene predict_proba() nativo — la calibración permite obtener el score de confianza |
Resultado: F1-macro = 0.96 en test set ✅
¿Por qué MLP en lugar de transformers?
El MLP también es aprendizaje supervisado — aprende ajustando sus pesos para minimizar el error entre la predicción y la etiqueta real. Con ~900 ejemplos de entrenamiento, los transformers (BERT, RoBERTa) tienden a hacer overfitting. Un MLP sobre features TF-IDF bien diseñadas es más eficiente y competitivo con datasets pequeños. Además, los transformers requieren GPU y torch — el MLP corre en CPU sin dependencias pesadas.
¿Por qué FeatureUnion con char n-grams?
La innovación de Model02 es combinar dos tipos de features en paralelo:
- word n-grams: captura frases clave completas (
"exportar shapefile","calcular área") - char n-grams: captura patrones de caracteres (
"calc","expo","buff") — más robusto ante errores tipográficos y variaciones morfológicas
Pipeline([
('features', FeatureUnion([
('word_tfidf', TfidfVectorizer(ngram_range=(1,2), analyzer='word')),
('char_tfidf', TfidfVectorizer(ngram_range=(2,4), analyzer='char_wb')),
])),
('chi2', SelectKBest(chi2, k=2000)),
('scaler', MaxAbsScaler()),
('clf', MLPClassifier(hidden_layer_sizes=(256,128), early_stopping=True)),
])| Componente | Por qué se usa |
|---|---|
FeatureUnion |
Combina en paralelo ambos vectorizadores — el vector final es la concatenación de ambas matrices |
char_wb n-grams (2,4) |
Captura morfología sin preprocesamiento — "calcular" y "calculando" comparten los mismos char n-grams |
MaxAbsScaler |
Normaliza features entre -1 y 1 sin destruir la sparsidad — necesario porque MLP es sensible a la escala |
MLPClassifier (256,128) |
Red neuronal densa de dos capas — aprende combinaciones no lineales de features |
early_stopping=True |
Detiene el entrenamiento cuando la validación deja de mejorar — evita overfitting |
Resultado: F1-macro = 0.88 en test set, pero más robusto con texto crudo en producción gracias a los char n-grams ✅
| Criterio | Model01 (SVM) | Model02 (MLP) |
|---|---|---|
| Tipo de aprendizaje | Supervisado | Supervisado |
| F1-macro test | 0.96 | 0.88 |
| Velocidad entrenamiento | Muy rápido | Lento |
| Robustez texto crudo | Baja | Alta |
| Interpretabilidad | Alta | Media |
| Memoria | Pequeña | Mayor |
| Uso recomendado | Evaluación académica | Producción |
¿Por qué Model02 en producción? Model01 gana en métricas pero fue evaluado con texto preprocesado (spaCy). Los char n-grams de Model02 hacen que sea más tolerante ante variaciones del usuario real. Aunque el preprocesamiento se aplica también en la API, Model02 ofrece mayor robustez como capa de seguridad adicional.
GridSearchCV — búsqueda exhaustiva (requerido por rúbrica):
cv = RepeatedStratifiedKFold(n_splits=10, n_repeats=10) # 100 folds
GridSearchCV(pipeline, param_grid, cv=cv, scoring='f1_macro', n_jobs=-1)
# Model01: 54 combinaciones × 100 folds = 5400 fits
# Model02: 48 combinaciones × 100 folds = 4800 fitsOptuna — búsqueda bayesiana TPE (extra):
study = optuna.create_study(direction='maximize',
sampler=optuna.samplers.TPESampler())
study.optimize(objective, n_trials=40)
# Converge a resultados similares con ~400 evaluaciones (10x menos)| Modelo | Accuracy | F1-macro | Condición |
|---|---|---|---|
| Model01 (SVM) | 0.96 | 0.96 | Texto preprocesado |
| Model02 (MLP) | 0.88 | 0.88 | Texto preprocesado |
p-valor < 0.05
✓ Diferencia SIGNIFICATIVA — Model01 es estadísticamente superior en CV
| Intent | Precision | Recall | F1 |
|---|---|---|---|
| buffer_analysis | 1.00 | 1.00 | 1.00 |
| calculate_area | 0.94 | 1.00 | 0.97 |
| export_data | 1.00 | 1.00 | 1.00 |
| get_attributes | 0.88 | 0.88 | 0.88 |
| query_layer | 0.88 | 0.94 | 0.91 |
| spatial_filter | 1.00 | 0.94 | 0.97 |
| spatial_join | 1.00 | 0.94 | 0.97 |
| visualize_map | 1.00 | 1.00 | 1.00 |
query_layeryvisualize_maptienen menor F1 porque comparten vocabulario ("mostrar", "mapa", "capas").
uv run uvicorn src.api.main:app --reload --port 8000POST /predict:
curl -X POST http://localhost:8000/predict \
-H "Content-Type: application/json" \
-d '{"text": "muéstrame las capas disponibles", "model": "model02"}'{"intent": "query_layer", "confidence": 0.9722, "agent": "geo_agent", "model": "model02"}GET /health: {"status": "ok", "model01": true, "model02": true}
WebSocket /speech: voz → Web Speech API → texto → classify → intent
Visor en http://localhost:8000/app — texto o voz, selector de modelo, resultado con confianza.
docker compose up --build # producción en localhost:8000
docker compose --profile dev up # desarrollo con hot-reload
docker compose down # deteneruv run pytest # 41/41 passed
uv run pytest --cov=src # con cobertura| Archivo | Qué verifica |
|---|---|
test_data.py |
Schema JSONL, intents presentes, sin textos vacíos |
test_features.py |
TF-IDF, bigramas, no data leakage, chi² |
test_models.py |
Pipelines, accuracy > 0.85, predict() válido |
test_api.py |
/predict 200, schema correcto, vacío → 422 |
notebooks/main.ipynb — reporte ejecutivo que carga datos y modelos ya generados.
| Sección | Descripción |
|---|---|
| 0. Setup | Imports, paths, constantes |
| 1. Introducción | Problema, propuesta, tabla de intents |
| 2. EDA | Distribución, longitud, n-grams por intent |
| 3. Preprocesamiento | Ejemplos antes/después de spaCy |
| 4. Feature Extraction | BoW vs TF-IDF vs TF-IDF+n-grams |
| 5. Pipelines | Definición y justificación de los 3 pipelines |
| 6. Optimización | GridSearchCV + F1 vs λ + curvas de aprendizaje + Optuna |
| 7. Comparación estadística | Wilcoxon 10×10 + boxplot |
| 8. Evaluación | Classification report + matrices de confusión |
| 9. t-SNE | Espacio de features en 2D |
| 10. Conclusiones | Resultados, limitaciones, recomendaciones |
| 11. Referencias | IEEE + prompts de IA |
[1] F. Pedregosa et al., "Scikit-learn: Machine Learning in Python," JMLR, vol. 12, pp. 2825–2830, 2011.
[2] G. Salton and C. Buckley, "Term-weighting approaches in automatic text retrieval," Information Processing & Management, vol. 24, no. 5, pp. 513–523, 1988.
[3] C. Cortes and V. Vapnik, "Support-vector networks," Machine Learning, vol. 20, no. 3, pp. 273–297, 1995.
[4] L. Van der Maaten and G. Hinton, "Visualizing Data using t-SNE," JMLR, vol. 9, pp. 2579–2605, 2008.
[5] T. Akiba et al., "Optuna: A Next-generation Hyperparameter Optimization Framework," KDD, 2019. https://optuna.org
[6] Explosion AI, "spaCy: Industrial-strength NLP," 2023. https://spacy.io
[7] GROQ Inc., "GROQ API Documentation," 2024. https://console.groq.com/docs
[8] F. Wilcoxon, "Individual Comparisons by Ranking Methods," Biometrics Bulletin, vol. 1, no. 6, pp. 80–83, 1945.
[9] Astral, "uv — An extremely fast Python package manager," 2024. https://github.com/astral-sh/uv
[10] J. Manning, P. Raghavan, and H. Schütze, Introduction to Information Retrieval. Cambridge University Press, 2008.
[11] Esri, "ArcGIS API for Python Documentation," 2024. [Online]. Available: https://developers.arcgis.com/python/
[12] Esri, "ArcPy Documentation," 2024. [Online]. Available: https://pro.arcgis.com/en/pro-app/arcpy/get-started/what-is-arcpy-.htm