"""
Utils
-----
:mod:`textacy.extract.utils`: Functions for working with extraction results.
"""
from __future__ import annotations
import itertools
import operator
import re
from typing import (
Callable,
Collection,
Dict,
Iterable,
List,
Optional,
Set,
Tuple,
)
from cytoolz import itertoolz
from spacy.tokens import Doc, Token
from .. import constants, errors, types, utils
from . import matches
[docs]def terms_to_strings(
terms: Iterable[types.SpanLike], by: str | Callable[[types.SpanLike], str],
) -> Iterable[str]:
"""
Transform a sequence of terms as spaCy ``Token`` s or ``Span`` s into strings.
Args:
terms
by: Method by which terms are transformed into strings.
If "orth", terms are represented by their text exactly as written;
if "lower", by the lowercased form of their text;
if "lemma", by their base form w/o inflectional suffixes;
if a callable, must accept a ``Token`` or ``Span`` and return a string.
Yields:
Next term in ``terms``, as a string.
"""
if by == "lower":
terms = (term.text.lower() for term in terms)
elif by in ("lemma", "orth"):
by_ = operator.attrgetter(f"{by}_")
terms = (by_(term) for term in terms)
elif callable(by):
terms = (by(term) for term in terms)
else:
raise ValueError(
errors.value_invalid_msg("by", by, {"orth", "lower", "lemma", Callable})
)
for term in terms:
yield term
[docs]def clean_term_strings(terms: Iterable[str]) -> Iterable[str]:
"""
Clean up a sequence of single- or multi-word terms as strings:
strip leading/trailing junk chars, handle dangling parens and odd hyphenation,
and normalize whitespace.
Args:
terms: Sequence of terms such as "environment", "plastic pollution",
or "fossil fuel industry" that may be _unclean_ for whatever reason.
Yields:
Next term in ``terms`` but with the cruft cleaned up, excluding terms
that were entirely cruft
Warning:
Terms with (intentionally) unusual punctuation may get "cleaned"
into a form that changes or obscures the original meaning of the term.
"""
# get rid of leading/trailing junk characters
terms = (constants.RE_LEAD_TAIL_CRUFT_TERM.sub("", term) for term in terms)
terms = (constants.RE_LEAD_HYPHEN_TERM.sub(r"\1", term) for term in terms)
# handle dangling/backwards parens, don't allow '(' or ')' to appear alone
terms = (
""
if term.count(")") != term.count("(") or term.find(")") < term.find("(")
else term
if "(" not in term
else constants.RE_DANGLING_PARENS_TERM.sub(r"\1\2\3", term)
for term in terms
)
# handle oddly separated hyphenated words
terms = (
term
if "-" not in term
else constants.RE_NEG_DIGIT_TERM.sub(
r"\1\2", constants.RE_WEIRD_HYPHEN_SPACE_TERM.sub(r"\1", term)
)
for term in terms
)
# handle oddly separated apostrophe'd words
terms = (
constants.RE_WEIRD_APOSTR_SPACE_TERM.sub(r"\1\2", term) if "'" in term else term
for term in terms
)
# normalize whitespace
terms = (constants.RE_NONBREAKING_SPACE.sub(" ", term).strip() for term in terms)
for term in terms:
if re.search(r"\w", term):
yield term
[docs]def aggregate_term_variants(
terms: Set[str],
*,
acro_defs: Optional[Dict[str, str]] = None,
fuzzy_dedupe: bool = True,
) -> List[Set[str]]:
"""
Take a set of unique terms and aggregate terms that are symbolic, lexical,
and ordering variants of each other, as well as acronyms and fuzzy string matches.
Args:
terms: Set of unique terms with potential duplicates
acro_defs: If not None, terms that are acronyms will be aggregated
with their definitions and terms that are definitions will be aggregated
with their acronyms
fuzzy_dedupe: If True, fuzzy string matching will be used
to aggregate similar terms of a sufficient length
Returns:
Each item is a set of aggregated terms.
Notes:
Partly inspired by aggregation of variants discussed in
Park, Youngja, Roy J. Byrd, and Branimir K. Boguraev.
"Automatic glossary extraction: beyond terminology identification."
Proceedings of the 19th international conference on Computational linguistics-
Volume 1. Association for Computational Linguistics, 2002.
"""
from .. import similarity # ugh, hide import here
agg_terms = []
seen_terms: Set[str] = set()
for term in sorted(terms, key=len, reverse=True):
if term in seen_terms:
continue
variants = set([term])
seen_terms.add(term)
# symbolic variations
if "-" in term:
variant = term.replace("-", " ").strip()
if variant in terms.difference(seen_terms):
variants.add(variant)
seen_terms.add(variant)
if "/" in term:
variant = term.replace("/", " ").strip()
if variant in terms.difference(seen_terms):
variants.add(variant)
seen_terms.add(variant)
# lexical variations
term_words = term.split()
# last_word = term_words[-1]
# # assume last word is a noun
# last_word_lemmatized = lemmatizer.lemmatize(last_word, 'n')
# # if the same, either already a lemmatized noun OR a verb; try verb
# if last_word_lemmatized == last_word:
# last_word_lemmatized = lemmatizer.lemmatize(last_word, 'v')
# # if at least we have a new term... add it
# if last_word_lemmatized != last_word:
# term_lemmatized = ' '.join(term_words[:-1] + [last_word_lemmatized])
# if term_lemmatized in terms.difference(seen_terms):
# variants.add(term_lemmatized)
# seen_terms.add(term_lemmatized)
# if term is an acronym, add its definition
# if term is a definition, add its acronym
if acro_defs:
for acro, def_ in acro_defs.items():
if acro.lower() == term.lower():
variants.add(def_.lower())
seen_terms.add(def_.lower())
break
elif def_.lower() == term.lower():
variants.add(acro.lower())
seen_terms.add(acro.lower())
break
# if 3+ -word term differs by one word at the start or the end
# of a longer phrase, aggregate
if len(term_words) > 2:
term_minus_first_word = " ".join(term_words[1:])
term_minus_last_word = " ".join(term_words[:-1])
if term_minus_first_word in terms.difference(seen_terms):
variants.add(term_minus_first_word)
seen_terms.add(term_minus_first_word)
if term_minus_last_word in terms.difference(seen_terms):
variants.add(term_minus_last_word)
seen_terms.add(term_minus_last_word)
# check for "X of Y" <=> "Y X" term variants
if " of " in term:
split_term = term.split(" of ")
variant = split_term[1] + " " + split_term[0]
if variant in terms.difference(seen_terms):
variants.add(variant)
seen_terms.add(variant)
# intense de-duping for sufficiently long terms
if fuzzy_dedupe is True and len(term) >= 13:
for other_term in sorted(
terms.difference(seen_terms), key=len, reverse=True
):
if len(other_term) < 13:
break
tsr = similarity.token_sort_ratio(term, other_term)
if tsr > 0.93:
variants.add(other_term)
seen_terms.add(other_term)
break
agg_terms.append(variants)
return agg_terms
[docs]def get_longest_subsequence_candidates(
doc: Doc, match_func: Callable[[Token], bool],
) -> Iterable[Tuple[Token, ...]]:
"""
Get candidate keyterms from ``doc``, where candidates are longest consecutive
subsequences of tokens for which all ``match_func(token)`` is True.
Args:
doc
match_func: Function applied sequentially to each ``Token`` in ``doc``
that returns True for matching ("good") tokens, False otherwise.
Yields:
Next longest consecutive subsequence candidate, as a tuple of constituent tokens.
"""
for key, words_grp in itertools.groupby(doc, key=match_func):
if key is True:
yield tuple(words_grp)
[docs]def get_ngram_candidates(
doc: Doc,
ns: int | Collection[int],
*,
include_pos: Optional[str | Collection[str]] = ("NOUN", "PROPN", "ADJ"),
) -> Iterable[Tuple[Token, ...]]:
"""
Get candidate keyterms from ``doc``, where candidates are n-length sequences
of tokens (for all n in ``ns``) that don't start/end with a stop word or
contain punctuation tokens, and whose constituent tokens are filtered by POS tag.
Args:
doc
ns: One or more n values for which to generate n-grams. For example,
``2`` gets bigrams; ``(2, 3)`` gets bigrams and trigrams.
include_pos: One or more POS tags with which to filter ngrams.
If None, include tokens of all POS tags.
Yields:
Next ngram candidate, as a tuple of constituent Tokens.
See Also:
:func:`textacy.extract.ngrams()`
"""
ns = utils.to_collection(ns, int, tuple)
include_pos = utils.to_collection(include_pos, str, set)
ngrams = itertoolz.concat(itertoolz.sliding_window(n, doc) for n in ns)
ngrams = (
ngram
for ngram in ngrams
if not (ngram[0].is_stop or ngram[-1].is_stop)
and not any(word.is_punct or word.is_space for word in ngram)
)
if include_pos:
ngrams = (
ngram for ngram in ngrams if all(word.pos_ in include_pos for word in ngram)
)
for ngram in ngrams:
yield ngram
[docs]def get_pattern_matching_candidates(
doc: Doc, patterns: str | List[str] | List[dict] | List[List[dict]],
) -> Iterable[Tuple[Token, ...]]:
"""
Get candidate keyterms from ``doc``, where candidates are sequences of tokens
that match any pattern in ``patterns``
Args:
doc
patterns: One or multiple patterns to match against ``doc`` using
a :class:`spacy.matcher.Matcher`.
Yields:
Tuple[:class:`spacy.tokens.Token`]: Next pattern-matching candidate,
as a tuple of constituent Tokens.
See Also:
:func:`textacy.extract.token_matches()`
"""
for match in matches.token_matches(doc, patterns, on_match=None):
yield tuple(match)
[docs]def get_filtered_topn_terms(
term_scores: Iterable[Tuple[str, float]],
topn: int,
*,
match_threshold: Optional[float] = None,
) -> List[Tuple[str, float]]:
"""
Build up a list of the ``topn`` terms, filtering out any that are substrings
of better-scoring terms and optionally filtering out any that are sufficiently
similar to better-scoring terms.
Args:
term_scores: Iterable of (term, score) pairs, sorted in order of score
from best to worst. Note that this may be from high to low value or
low to high, depending on the scoring algorithm.
topn: Maximum number of top-scoring terms to get.
match_threshold: Minimal edit distance between a term and previously seen terms,
used to filter out terms that are sufficiently similar
to higher-scoring terms. Uses :func:`textacy.similarity.token_sort_ratio()`.
"""
from .. import similarity # ugh, hide import here
topn_terms = []
seen_terms: Set[str] = set()
sim_func = similarity.token_sort_ratio
for term, score in term_scores:
# skip terms that are substrings of any higher-scoring term
if any(term in st for st in seen_terms):
continue
# skip terms that are sufficiently similar to any higher-scoring term
if match_threshold and any(
sim_func(term, st) >= match_threshold for st in seen_terms
):
continue
seen_terms.add(term)
topn_terms.append((term, score))
if len(topn_terms) >= topn:
break
return topn_terms
# TODO: decide if we want to keep this function, and if so, where to put it
# import math
# from operator import itemgetter
# from decimal import Decimal
# import numpy as np
# from .. import vsm
# def most_discriminating_terms(
# terms_lists: Iterable[Iterable[str]],
# bool_array_grp1: Iterable[bool],
# *,
# max_n_terms: int = 1000,
# top_n_terms: int | float = 25,
# ) -> Tuple[List[str], List[str]]:
# """
# Given a collection of documents assigned to 1 of 2 exclusive groups, get the
# ``top_n_terms`` most discriminating terms for group1-and-not-group2 and
# group2-and-not-group1.
# Args:
# terms_lists: Sequence of documents, each as a sequence of (str) terms;
# used as input to :func:`doc_term_matrix()`
# bool_array_grp1: Ordered sequence of True/False values,
# where True corresponds to documents falling into "group 1" and False
# corresponds to those in "group 2".
# max_n_terms: Only consider terms whose document frequency is within
# the top ``max_n_terms`` out of all distinct terms; must be > 0.
# top_n_terms: If int (must be > 0), the total number of most discriminating terms
# to return for each group; if float (must be in the interval (0, 1)),
# the fraction of ``max_n_terms`` to return for each group.
# Returns:
# List of the top ``top_n_terms`` most discriminating terms for grp1-not-grp2, and
# list of the top ``top_n_terms`` most discriminating terms for grp2-not-grp1.
# References:
# King, Gary, Patrick Lam, and Margaret Roberts. "Computer-Assisted Keyword
# and Document Set Discovery from Unstructured Text." (2014).
# http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.458.1445&rep=rep1&type=pdf
# """
# alpha_grp1 = 1
# alpha_grp2 = 1
# if isinstance(top_n_terms, float):
# top_n_terms = int(round(top_n_terms * max_n_terms))
# bool_array_grp1 = np.array(bool_array_grp1)
# bool_array_grp2 = np.invert(bool_array_grp1)
# vectorizer = vsm.Vectorizer(
# tf_type="linear",
# norm=None,
# idf_type="smooth",
# min_df=3,
# max_df=0.95,
# max_n_terms=max_n_terms,
# )
# dtm = vectorizer.fit_transform(terms_lists)
# id2term = vectorizer.id_to_term
# # get doc freqs for all terms in grp1 documents
# dtm_grp1 = dtm[bool_array_grp1, :]
# n_docs_grp1 = dtm_grp1.shape[0]
# doc_freqs_grp1 = vsm.get_doc_freqs(dtm_grp1)
# # get doc freqs for all terms in grp2 documents
# dtm_grp2 = dtm[bool_array_grp2, :]
# n_docs_grp2 = dtm_grp2.shape[0]
# doc_freqs_grp2 = vsm.get_doc_freqs(dtm_grp2)
# # get terms that occur in a larger fraction of grp1 docs than grp2 docs
# term_ids_grp1 = np.where(
# doc_freqs_grp1 / n_docs_grp1 > doc_freqs_grp2 / n_docs_grp2
# )[0]
# # get terms that occur in a larger fraction of grp2 docs than grp1 docs
# term_ids_grp2 = np.where(
# doc_freqs_grp1 / n_docs_grp1 < doc_freqs_grp2 / n_docs_grp2
# )[0]
# # get grp1 terms doc freqs in and not-in grp1 and grp2 docs, plus marginal totals
# grp1_terms_grp1_df = doc_freqs_grp1[term_ids_grp1]
# grp1_terms_grp2_df = doc_freqs_grp2[term_ids_grp1]
# # grp1_terms_grp1_not_df = n_docs_grp1 - grp1_terms_grp1_df
# # grp1_terms_grp2_not_df = n_docs_grp2 - grp1_terms_grp2_df
# # grp1_terms_total_df = grp1_terms_grp1_df + grp1_terms_grp2_df
# # grp1_terms_total_not_df = grp1_terms_grp1_not_df + grp1_terms_grp2_not_df
# # get grp2 terms doc freqs in and not-in grp2 and grp1 docs, plus marginal totals
# grp2_terms_grp2_df = doc_freqs_grp2[term_ids_grp2]
# grp2_terms_grp1_df = doc_freqs_grp1[term_ids_grp2]
# # grp2_terms_grp2_not_df = n_docs_grp2 - grp2_terms_grp2_df
# # grp2_terms_grp1_not_df = n_docs_grp1 - grp2_terms_grp1_df
# # grp2_terms_total_df = grp2_terms_grp2_df + grp2_terms_grp1_df
# # grp2_terms_total_not_df = grp2_terms_grp2_not_df + grp2_terms_grp1_not_df
# # get grp1 terms likelihoods, then sort for most discriminating grp1-not-grp2 terms
# grp1_terms_likelihoods = {}
# for idx, term_id in enumerate(term_ids_grp1):
# term1 = (
# Decimal(math.factorial(grp1_terms_grp1_df[idx] + alpha_grp1 - 1))
# * Decimal(math.factorial(grp1_terms_grp2_df[idx] + alpha_grp2 - 1))
# / Decimal(
# math.factorial(
# grp1_terms_grp1_df[idx]
# + grp1_terms_grp2_df[idx]
# + alpha_grp1
# + alpha_grp2
# - 1
# )
# )
# )
# term2 = (
# Decimal(
# math.factorial(n_docs_grp1 - grp1_terms_grp1_df[idx] + alpha_grp1 - 1)
# )
# * Decimal(
# math.factorial(n_docs_grp2 - grp1_terms_grp2_df[idx] + alpha_grp2 - 1)
# )
# / Decimal(
# (
# math.factorial(
# n_docs_grp1
# + n_docs_grp2
# - grp1_terms_grp1_df[idx]
# - grp1_terms_grp2_df[idx]
# + alpha_grp1
# + alpha_grp2
# - 1
# )
# )
# )
# )
# grp1_terms_likelihoods[id2term[term_id]] = term1 * term2
# top_grp1_terms = [
# term
# for term, likelihood in sorted(
# grp1_terms_likelihoods.items(), key=itemgetter(1), reverse=True
# )[:top_n_terms]
# ]
# # get grp2 terms likelihoods, then sort for most discriminating grp2-not-grp1 terms
# grp2_terms_likelihoods = {}
# for idx, term_id in enumerate(term_ids_grp2):
# term1 = (
# Decimal(math.factorial(grp2_terms_grp2_df[idx] + alpha_grp2 - 1))
# * Decimal(math.factorial(grp2_terms_grp1_df[idx] + alpha_grp1 - 1))
# / Decimal(
# math.factorial(
# grp2_terms_grp2_df[idx]
# + grp2_terms_grp1_df[idx]
# + alpha_grp2
# + alpha_grp1
# - 1
# )
# )
# )
# term2 = (
# Decimal(
# math.factorial(n_docs_grp2 - grp2_terms_grp2_df[idx] + alpha_grp2 - 1)
# )
# * Decimal(
# math.factorial(n_docs_grp1 - grp2_terms_grp1_df[idx] + alpha_grp1 - 1)
# )
# / Decimal(
# (
# math.factorial(
# n_docs_grp2
# + n_docs_grp1
# - grp2_terms_grp2_df[idx]
# - grp2_terms_grp1_df[idx]
# + alpha_grp2
# + alpha_grp1
# - 1
# )
# )
# )
# )
# grp2_terms_likelihoods[id2term[term_id]] = term1 * term2
# top_grp2_terms = [
# term
# for term, likelihood in sorted(
# grp2_terms_likelihoods.items(), key=itemgetter(1), reverse=True
# )[:top_n_terms]
# ]
# return (top_grp1_terms, top_grp2_terms)