frimeet-api-nlp / app /modules /posts /application /use_cases /train_post_clusters.py
AlleksDev's picture
Fix: Custom Feed
5c793cc unverified
Raw
History Blame Contribute Delete
4.05 kB
import asyncio
from collections import Counter
from math import sqrt
from app.modules.posts.domain.clustering import ClusterRunResult, KMeansResult
from app.modules.posts.domain.ports.cluster_training_repository import ClusterTrainingRepository
from app.modules.posts.domain.ports.clusterer import PostClusterer
class TrainPostClustersUseCase:
ALGORITHM_VERSION = "sklearn-minibatch-kmeans-v1"
def __init__(
self,
repository: ClusterTrainingRepository,
clusterer: PostClusterer,
min_posts: int = 20,
min_k: int = 2,
max_k: int = 50,
lookback_days: int = 90,
min_cluster_size: int = 2,
max_cluster_ratio: float = 0.70,
auto_activate: bool = False,
) -> None:
self._repository = repository
self._clusterer = clusterer
self._min_posts = min_posts
self._min_k = min_k
self._max_k = max_k
self._lookback_days = lookback_days
self._min_cluster_size = min_cluster_size
self._max_cluster_ratio = max_cluster_ratio
self._auto_activate = auto_activate
async def execute(self) -> ClusterRunResult:
embeddings = await self._repository.list_active_embeddings(self._lookback_days)
if len(embeddings) < self._min_posts:
raise ValueError(
f"se requieren al menos {self._min_posts} posts para entrenar K-Means"
)
estimated = round(sqrt(len(embeddings) / 2))
upper = min(self._max_k, len(embeddings) - 1)
lower = min(self._min_k, upper)
candidate_ks = sorted(
{
max(lower, min(upper, value))
for value in (estimated - 4, estimated, estimated + 4)
}
)
run_id = await self._repository.create_run(
planned_k=candidate_ks[len(candidate_ks) // 2],
sample_size=len(embeddings),
algorithm_version=self.ALGORITHM_VERSION,
parameters={
"candidate_ks": ",".join(str(value) for value in candidate_ks),
"min_cluster_size": self._min_cluster_size,
"max_cluster_ratio": self._max_cluster_ratio,
},
)
try:
evaluated: list[tuple[int, KMeansResult]] = []
for k in candidate_ks:
result = await asyncio.to_thread(self._clusterer.fit, embeddings, k)
if _is_valid_distribution(
result,
len(embeddings),
self._min_cluster_size,
self._max_cluster_ratio,
):
evaluated.append((k, result))
if not evaluated:
raise ValueError("ningun K produjo una distribucion de clusters valida")
k, result = max(
evaluated,
key=lambda item: (
item[1].silhouette_score
if item[1].silhouette_score is not None
else float("-inf"),
-item[1].inertia,
),
)
await self._repository.save_validated_run(run_id, k, result)
status = "validated"
if self._auto_activate:
await self._repository.activate_run(run_id)
status = "active"
return ClusterRunResult(
run_id,
k,
len(embeddings),
result.inertia,
result.silhouette_score,
status,
)
except Exception as exc:
await self._repository.mark_run_failed(run_id, str(exc))
raise
def _is_valid_distribution(
result: KMeansResult,
sample_size: int,
min_cluster_size: int,
max_cluster_ratio: float,
) -> bool:
counts = Counter(item.cluster_id for item in result.assignments)
if not counts or min(counts.values()) < min_cluster_size:
return False
return max(counts.values()) / sample_size <= max_cluster_ratio