Pular para conteúdo

Algoritmos Base

Os responsáveis por comparar estatisticamente os vetores das palavras, fonemas e blocos.

Bases: ABC

Interface base para algoritmos de cálculo de similaridade entre duas strings.

Source code in src/text_similarity/core/base.py
class SimilarityAlgorithm(abc.ABC):
    """Interface base para algoritmos de cálculo de similaridade entre duas strings."""

    @abc.abstractmethod
    def compare(self, text1: str, text2: str) -> float:
        """Recebe dois textos pré-processados e retorna um score numérico.

        A pontuação varia estritamente de 0.0 (totalmente diferente) a 1.0 (idêntico).
        """
        pass

compare(text1, text2) abstractmethod

Recebe dois textos pré-processados e retorna um score numérico.

A pontuação varia estritamente de 0.0 (totalmente diferente) a 1.0 (idêntico).

Source code in src/text_similarity/core/base.py
@abc.abstractmethod
def compare(self, text1: str, text2: str) -> float:
    """Recebe dois textos pré-processados e retorna um score numérico.

    A pontuação varia estritamente de 0.0 (totalmente diferente) a 1.0 (idêntico).
    """
    pass

Bases: SimilarityAlgorithm

Calcula similaridade cosseno utilizando TF-IDF.

Bom para avaliar sobreposição de vocabulário e contexto global.

Source code in src/text_similarity/core/cosine.py
class CosineSimilarity(SimilarityAlgorithm):
    """Calcula similaridade cosseno utilizando TF-IDF.

    Bom para avaliar sobreposição de vocabulário e contexto global.
    """

    def __init__(self, ngram_range: tuple[int, int] = (1, 2)) -> None:
        """Inicializa configurações de tokenização TF-IDF."""
        # Usa bigramas por padrão para capturar contexto local ("s22 ultra")
        self.ngram_range = ngram_range

    def compare(self, text1: str, text2: str) -> float:
        """Extrai os tokens TF-IDF e os avalia por distância Vetorial."""
        if not text1 or not text2:
            return 0.0

        # Utilizamos o TfidfVectorizer para computar a similaridade dos dois textos.
        # Em cenários de busca massiva um "VectorSpaceModel" pré-treinado seria
        # injetado, mas como algoritmo atômico fazemos o fit nele mesmo.
        vectorizer = TfidfVectorizer(ngram_range=self.ngram_range, min_df=1)

        try:
            tfidf_matrix = vectorizer.fit_transform([text1, text2])
            # tfidf_matrix[0:1] calcula contra matriz inteira,
            # [0][1] extrai o cruzamento.
            score = sklearn_cosine_similarity(tfidf_matrix[0:1], tfidf_matrix)[0][1]
            # cast garante o tipo pois sklearn retorna numpy.float64
            return float(score)
        except ValueError:
            # Caso "After pruning, no terms remain." ou outras exceções de vocabulário
            return 0.0

__init__(ngram_range=(1, 2))

Inicializa configurações de tokenização TF-IDF.

Source code in src/text_similarity/core/cosine.py
def __init__(self, ngram_range: tuple[int, int] = (1, 2)) -> None:
    """Inicializa configurações de tokenização TF-IDF."""
    # Usa bigramas por padrão para capturar contexto local ("s22 ultra")
    self.ngram_range = ngram_range

compare(text1, text2)

Extrai os tokens TF-IDF e os avalia por distância Vetorial.

Source code in src/text_similarity/core/cosine.py
def compare(self, text1: str, text2: str) -> float:
    """Extrai os tokens TF-IDF e os avalia por distância Vetorial."""
    if not text1 or not text2:
        return 0.0

    # Utilizamos o TfidfVectorizer para computar a similaridade dos dois textos.
    # Em cenários de busca massiva um "VectorSpaceModel" pré-treinado seria
    # injetado, mas como algoritmo atômico fazemos o fit nele mesmo.
    vectorizer = TfidfVectorizer(ngram_range=self.ngram_range, min_df=1)

    try:
        tfidf_matrix = vectorizer.fit_transform([text1, text2])
        # tfidf_matrix[0:1] calcula contra matriz inteira,
        # [0][1] extrai o cruzamento.
        score = sklearn_cosine_similarity(tfidf_matrix[0:1], tfidf_matrix)[0][1]
        # cast garante o tipo pois sklearn retorna numpy.float64
        return float(score)
    except ValueError:
        # Caso "After pruning, no terms remain." ou outras exceções de vocabulário
        return 0.0

Bases: SimilarityAlgorithm

Calcula similaridade baseada em Distância de Edição / Levenshtein.

Utiliza o módulo ultrarrápido rapidfuzz.

Source code in src/text_similarity/core/rapidfuzz_cmp.py
class EditDistanceSimilarity(SimilarityAlgorithm):
    """Calcula similaridade baseada em Distância de Edição / Levenshtein.

    Utiliza o módulo ultrarrápido rapidfuzz.
    """

    def __init__(self, method: str = "ratio") -> None:
        """Inicialização baseada na estratégia de distância a ser utilizada.

        Args:
            method: 'ratio' (Levenshtein puro) ou
                'partial_ratio' (Bom para pedaços inclusos em palavras maiores),
                'token_sort_ratio' (Não importa a ordem das palavras).
        """
        self.method = method

    def compare(self, text1: str, text2: str) -> float:
        """Aciona a comparação dependendo do método parametrizado retornando o score."""
        if not text1 or not text2:
            return 0.0

        if self.method == "partial_ratio":
            # RapidFuzz retorna 0-100, nós retornamos 0.0 - 1.0
            return float(fuzz.partial_ratio(text1, text2)) / 100.0
        elif self.method == "token_sort_ratio":
            return float(fuzz.token_sort_ratio(text1, text2)) / 100.0
        else:
            return float(fuzz.ratio(text1, text2)) / 100.0

__init__(method='ratio')

Inicialização baseada na estratégia de distância a ser utilizada.

Parameters:

Name Type Description Default
method str

'ratio' (Levenshtein puro) ou 'partial_ratio' (Bom para pedaços inclusos em palavras maiores), 'token_sort_ratio' (Não importa a ordem das palavras).

'ratio'
Source code in src/text_similarity/core/rapidfuzz_cmp.py
def __init__(self, method: str = "ratio") -> None:
    """Inicialização baseada na estratégia de distância a ser utilizada.

    Args:
        method: 'ratio' (Levenshtein puro) ou
            'partial_ratio' (Bom para pedaços inclusos em palavras maiores),
            'token_sort_ratio' (Não importa a ordem das palavras).
    """
    self.method = method

compare(text1, text2)

Aciona a comparação dependendo do método parametrizado retornando o score.

Source code in src/text_similarity/core/rapidfuzz_cmp.py
def compare(self, text1: str, text2: str) -> float:
    """Aciona a comparação dependendo do método parametrizado retornando o score."""
    if not text1 or not text2:
        return 0.0

    if self.method == "partial_ratio":
        # RapidFuzz retorna 0-100, nós retornamos 0.0 - 1.0
        return float(fuzz.partial_ratio(text1, text2)) / 100.0
    elif self.method == "token_sort_ratio":
        return float(fuzz.token_sort_ratio(text1, text2)) / 100.0
    else:
        return float(fuzz.ratio(text1, text2)) / 100.0

Bases: SimilarityAlgorithm

Calcula similaridade fonética baseada em heurísticas para PT-BR.

Utilizamos um algoritmo de substituição fonética rudimentar que cobre 80% dos casos de erros de digitação auditivos em português (s/ss/z/c/ç), aliado à distância Levenshtein.

Source code in src/text_similarity/core/phonetic.py
class PhoneticSimilarity(SimilarityAlgorithm):
    """Calcula similaridade fonética baseada em heurísticas para PT-BR.

    Utilizamos um algoritmo de substituição fonética rudimentar que cobre 80%
    dos casos de erros de digitação auditivos em português (s/ss/z/c/ç),
    aliado à distância Levenshtein.
    """

    # Substituições multi-caractere (ordem importa: sorted por len desc)
    _MULTI_CHAR_MAP = {
        "ss": "s",
        "rr": "r",
        "ll": "l",
        "ce": "se",
        "ci": "si",
        "ch": "x",
        "qu": "k",
        "ge": "je",
        "gi": "ji",
        "lh": "l",
        "nh": "n",
    }
    _MULTI_RE = re.compile(
        "|".join(re.escape(k) for k in sorted(_MULTI_CHAR_MAP, key=len, reverse=True))
    )
    _SINGLE_MAP = {"c": "k", "h": ""}
    _SINGLE_RE = re.compile(r"[ch]")

    def _phonetic_hash(self, text: str) -> str:
        """Converte o texto para uma representação aproximada do som em PT-BR.

        Ex: "casa" -> "kaza", "passarinho" -> "pasarinho", "exceção" -> "esesso",
        "fazenda" -> "fazenda".
        """
        # Removendo todos os acentos que sobraram
        # Nota: após NFKD + ASCII ignore, ç já vira c
        text = (
            unicodedata.normalize("NFKD", text)
            .encode("ASCII", "ignore")
            .decode("utf-8")
        )
        text = text.lower()

        # Substituições multi-caractere em uma passada
        text = self._MULTI_RE.sub(lambda m: self._MULTI_CHAR_MAP[m.group()], text)

        # Substituições single-char (c->k, h->"")
        text = self._SINGLE_RE.sub(lambda m: self._SINGLE_MAP[m.group()], text)

        # M final -> N (bem -> ben)
        if text.endswith("m"):
            text = text[:-1] + "n"

        return text

    def compare(self, text1: str, text2: str) -> float:
        """Trata cada palavra do texto para fonemas, as une e calcula Levenshtein."""
        if not text1 or not text2:
            return 0.0

        hash1 = " ".join([self._phonetic_hash(word) for word in text1.split()])
        hash2 = " ".join([self._phonetic_hash(word) for word in text2.split()])

        # Utilizamos o rapidfuzz (ratio / levenshtein) no hash fonético resultante
        return float(fuzz.ratio(hash1, hash2)) / 100.0

compare(text1, text2)

Trata cada palavra do texto para fonemas, as une e calcula Levenshtein.

Source code in src/text_similarity/core/phonetic.py
def compare(self, text1: str, text2: str) -> float:
    """Trata cada palavra do texto para fonemas, as une e calcula Levenshtein."""
    if not text1 or not text2:
        return 0.0

    hash1 = " ".join([self._phonetic_hash(word) for word in text1.split()])
    hash2 = " ".join([self._phonetic_hash(word) for word in text2.split()])

    # Utilizamos o rapidfuzz (ratio / levenshtein) no hash fonético resultante
    return float(fuzz.ratio(hash1, hash2)) / 100.0

Bases: SimilarityAlgorithm

Calcula uma similaridade combinada (ponderada) utilizando múltiplos.

Modelos disponíveis (TF-IDF Cosseno, Distância de Edição e Fonética).

Source code in src/text_similarity/core/hybrid.py
class HybridSimilarity(SimilarityAlgorithm):
    """Calcula uma similaridade combinada (ponderada) utilizando múltiplos.

    Modelos disponíveis (TF-IDF Cosseno, Distância de Edição e Fonética).
    """

    def __init__(
        self,
        weights: dict[str, float] | None = None,
        target_entities: list[str] | None = None,
    ) -> None:
        """Inicializa agregador de distâncias computacionais simultâneas.

        Args:
            weights: Dicionário identificando o peso relativo de cada
                algoritmo no resultado final. Padrão:
                ``{"cosine": 0.35, "edit": 0.35,
                "phonetic": 0.15, "entity": 0.15}``.
            target_entities: Lista de tipos de entidade para filtrar no
                EntityIntersectionSimilarity (ex: ["productmodel"]).
                Se None, considera qualquer tag no padrão <X:Y>.
        """
        self.weights = weights or {
            "cosine": 0.35,
            "edit": 0.35,
            "phonetic": 0.15,
            "entity": 0.15,
            "semantic": 0.0,
        }

        # Normalizando pesos para somarem 1.0
        total_weight = sum(self.weights.values())
        if total_weight == 0:
            total_weight = 1.0
        self.weights = {k: v / total_weight for k, v in self.weights.items()}

        # Instanciando algoritmos base
        self.algorithms: dict[str, SimilarityAlgorithm] = {
            "cosine": CosineSimilarity(),
            "edit": EditDistanceSimilarity(method="ratio"),
            "phonetic": PhoneticSimilarity(),
            "entity": EntityIntersectionSimilarity(target_entities=target_entities),
        }

        # Instanciar SemanticSimilarity APENAS se ativado para evitar overhead
        if self.weights.get("semantic", 0.0) > 0.0:
            from text_similarity.core.semantic import SemanticSimilarity

            self.algorithms["semantic"] = SemanticSimilarity()

    def compare(self, text1: str, text2: str) -> float:
        """Soma iterativamente as ponderações de cada algoritmo.

        Aplica avaliação de short-circuit via algoritmo de entidade se este
        apontar similaridade total (1.0).
        """
        if not text1 or not text2:
            return 0.0

        # Avaliação de short-circuit para entidades (ex: product models)
        if "entity" in self.weights and self.weights["entity"] > 0:
            entity_score = self.algorithms["entity"].compare(text1, text2)
            if entity_score >= 1.0:
                # Se houver contenção total da entidade procurada, assegura alto score
                return 0.95

        final_score = 0.0
        for name, alg in self.algorithms.items():
            if name in self.weights and self.weights[name] > 0:
                score = alg.compare(text1, text2)
                final_score += score * self.weights[name]

        return final_score

    def explain(self, text1: str, text2: str) -> dict[str, Any]:
        """Funcionalidade especial sugerida: explica a contribuição de cada."""
        if not text1 or not text2:
            return {"score": 0.0, "details": {}}

        # Short-circuit de entidade — mesmo comportamento do compare()
        if "entity" in self.weights and self.weights["entity"] > 0:
            entity_score = self.algorithms["entity"].compare(text1, text2)
            if entity_score >= 1.0:
                return {
                    "score": 0.95,
                    "details": {
                        "entity": {
                            "score": entity_score,
                            "weight": self.weights["entity"],
                            "short_circuit": True,
                        }
                    },
                }

        details = {}
        final_score = 0.0
        for name, alg in self.algorithms.items():
            if name in self.weights and self.weights[name] > 0:
                score = alg.compare(text1, text2)
                details[name] = {"score": score, "weight": self.weights[name]}
                final_score += score * self.weights[name]

        return {"score": final_score, "details": details}

__init__(weights=None, target_entities=None)

Inicializa agregador de distâncias computacionais simultâneas.

Parameters:

Name Type Description Default
weights dict[str, float] | None

Dicionário identificando o peso relativo de cada algoritmo no resultado final. Padrão: {"cosine": 0.35, "edit": 0.35, "phonetic": 0.15, "entity": 0.15}.

None
target_entities list[str] | None

Lista de tipos de entidade para filtrar no EntityIntersectionSimilarity (ex: ["productmodel"]). Se None, considera qualquer tag no padrão .

None
Source code in src/text_similarity/core/hybrid.py
def __init__(
    self,
    weights: dict[str, float] | None = None,
    target_entities: list[str] | None = None,
) -> None:
    """Inicializa agregador de distâncias computacionais simultâneas.

    Args:
        weights: Dicionário identificando o peso relativo de cada
            algoritmo no resultado final. Padrão:
            ``{"cosine": 0.35, "edit": 0.35,
            "phonetic": 0.15, "entity": 0.15}``.
        target_entities: Lista de tipos de entidade para filtrar no
            EntityIntersectionSimilarity (ex: ["productmodel"]).
            Se None, considera qualquer tag no padrão <X:Y>.
    """
    self.weights = weights or {
        "cosine": 0.35,
        "edit": 0.35,
        "phonetic": 0.15,
        "entity": 0.15,
        "semantic": 0.0,
    }

    # Normalizando pesos para somarem 1.0
    total_weight = sum(self.weights.values())
    if total_weight == 0:
        total_weight = 1.0
    self.weights = {k: v / total_weight for k, v in self.weights.items()}

    # Instanciando algoritmos base
    self.algorithms: dict[str, SimilarityAlgorithm] = {
        "cosine": CosineSimilarity(),
        "edit": EditDistanceSimilarity(method="ratio"),
        "phonetic": PhoneticSimilarity(),
        "entity": EntityIntersectionSimilarity(target_entities=target_entities),
    }

    # Instanciar SemanticSimilarity APENAS se ativado para evitar overhead
    if self.weights.get("semantic", 0.0) > 0.0:
        from text_similarity.core.semantic import SemanticSimilarity

        self.algorithms["semantic"] = SemanticSimilarity()

compare(text1, text2)

Soma iterativamente as ponderações de cada algoritmo.

Aplica avaliação de short-circuit via algoritmo de entidade se este apontar similaridade total (1.0).

Source code in src/text_similarity/core/hybrid.py
def compare(self, text1: str, text2: str) -> float:
    """Soma iterativamente as ponderações de cada algoritmo.

    Aplica avaliação de short-circuit via algoritmo de entidade se este
    apontar similaridade total (1.0).
    """
    if not text1 or not text2:
        return 0.0

    # Avaliação de short-circuit para entidades (ex: product models)
    if "entity" in self.weights and self.weights["entity"] > 0:
        entity_score = self.algorithms["entity"].compare(text1, text2)
        if entity_score >= 1.0:
            # Se houver contenção total da entidade procurada, assegura alto score
            return 0.95

    final_score = 0.0
    for name, alg in self.algorithms.items():
        if name in self.weights and self.weights[name] > 0:
            score = alg.compare(text1, text2)
            final_score += score * self.weights[name]

    return final_score

explain(text1, text2)

Funcionalidade especial sugerida: explica a contribuição de cada.

Source code in src/text_similarity/core/hybrid.py
def explain(self, text1: str, text2: str) -> dict[str, Any]:
    """Funcionalidade especial sugerida: explica a contribuição de cada."""
    if not text1 or not text2:
        return {"score": 0.0, "details": {}}

    # Short-circuit de entidade — mesmo comportamento do compare()
    if "entity" in self.weights and self.weights["entity"] > 0:
        entity_score = self.algorithms["entity"].compare(text1, text2)
        if entity_score >= 1.0:
            return {
                "score": 0.95,
                "details": {
                    "entity": {
                        "score": entity_score,
                        "weight": self.weights["entity"],
                        "short_circuit": True,
                    }
                },
            }

    details = {}
    final_score = 0.0
    for name, alg in self.algorithms.items():
        if name in self.weights and self.weights[name] > 0:
            score = alg.compare(text1, text2)
            details[name] = {"score": score, "weight": self.weights[name]}
            final_score += score * self.weights[name]

    return {"score": final_score, "details": details}