Merge commit 'aa4fdc10876f29f7496cbac2cdf6fb7ed381db25' as 'utils_v2'
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
"""
|
||||
|
||||
AUTHOR:
|
||||
|
||||
Khushal P Soonderji
|
||||
|
||||
DATE:
|
||||
|
||||
Saturday, 18th May, 2024.
|
||||
|
||||
OBJECTIVE:
|
||||
|
||||
To provide a quick set of functions to work with fuzzy logic.
|
||||
|
||||
REFERENCES:
|
||||
|
||||
N/A
|
||||
|
||||
DOWNLOADS:
|
||||
|
||||
N/A
|
||||
|
||||
"""
|
||||
|
||||
|
||||
# *****************************************************************************************************************
|
||||
# ***** ****
|
||||
# *** IMPORT ***
|
||||
# ***** ****
|
||||
# *****************************************************************************************************************
|
||||
|
||||
|
||||
# To make sibling directories accessible for imports:
|
||||
import sys
|
||||
sys.path.append(".")
|
||||
sys.path.append("..")
|
||||
|
||||
# To apply fuzzy logic:
|
||||
from thefuzz import fuzz, process
|
||||
|
||||
# To work with datatypes:
|
||||
from typing import List
|
||||
|
||||
|
||||
# *****************************************************************************************************************
|
||||
# ***** ****
|
||||
# *** MACROS / ONE-TIME INIT ***
|
||||
# ***** ****
|
||||
# *****************************************************************************************************************
|
||||
|
||||
|
||||
# --- Nothing Yet
|
||||
|
||||
|
||||
# *****************************************************************************************************************
|
||||
# ***** ****
|
||||
# *** VARIABLES ***
|
||||
# ***** ****
|
||||
# *****************************************************************************************************************
|
||||
|
||||
|
||||
# --- Nothing Yet
|
||||
|
||||
|
||||
# *****************************************************************************************************************
|
||||
# ***** ****
|
||||
# *** FUNCTIONS ***
|
||||
# ***** ****
|
||||
# *****************************************************************************************************************
|
||||
|
||||
|
||||
def get_best_match(
|
||||
target: str,
|
||||
choices: list[str],
|
||||
threshold: float = 0.70,
|
||||
partial: bool = False
|
||||
) -> str | None:
|
||||
|
||||
"""
|
||||
Given a list of options, this finds the best match to the target string as long as it fits within the similarity
|
||||
threshold.
|
||||
:param target: The string whose closest match needs to be found.
|
||||
:param choices: The list of options to match the target against.
|
||||
:param threshold: The absolute lowest similarity value to consider a match. Ranges from 0 to 1.
|
||||
:param partial: If partial string matches are allowed.
|
||||
:return: The string from the list of choices that is the closes match if it fits within the threshold, or None.
|
||||
"""
|
||||
|
||||
# The input needs to be a string:
|
||||
if not isinstance(target, str): return None
|
||||
|
||||
# Pick a scoring mechanism based on whether, or not, partial matches are allowed:
|
||||
if partial: scorer = fuzz.partial_token_sort_ratio
|
||||
else: scorer = fuzz.ratio
|
||||
|
||||
# Find the best match:
|
||||
result = process.extractOne(
|
||||
target,
|
||||
choices,
|
||||
score_cutoff = threshold * 100,
|
||||
scorer = scorer
|
||||
)
|
||||
|
||||
# Done here:
|
||||
return result[0] if result else None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_match_score(
|
||||
target: str,
|
||||
choice: str,
|
||||
partial: bool = False
|
||||
) -> float:
|
||||
|
||||
"""
|
||||
Given a target and a choice to be considered, what is the similarity score of the choice to the target.
|
||||
:param target: The string whose closest match needs to be found.
|
||||
:param choice: The string that you want to test the target against.
|
||||
:param partial: If partial string matches are allowed.
|
||||
:return: The similarity score between the target and the choice (option) in the range from 0 to 1.
|
||||
"""
|
||||
|
||||
# The input needs to be a string:
|
||||
if not isinstance(target, str): return 0.0
|
||||
|
||||
# Pick a scoring mechanism based on whether, or not, partial matches are allowed:
|
||||
if partial: scorer = fuzz.partial_token_sort_ratio
|
||||
else: scorer = fuzz.ratio
|
||||
|
||||
# Find the best match with not threshold cut-off:
|
||||
result = process.extractOne(
|
||||
target,
|
||||
choices = [choice],
|
||||
score_cutoff = 0.0,
|
||||
scorer = scorer
|
||||
)
|
||||
|
||||
# Done here:
|
||||
return result[1] / 100.0 if result else 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def rank(
|
||||
target: str,
|
||||
choices: List[str],
|
||||
partial: bool = False
|
||||
) -> List[dict] | None:
|
||||
|
||||
"""
|
||||
To rank the similarity of all the options against the target.
|
||||
:param target: The string against which all options need to be matched.
|
||||
:param choices: The list of strings that need to be ranked for similarity with the target.
|
||||
:param partial: If partial string matches are allowed.
|
||||
:return: A list of all the choices with their similarity scores sorted from most to least similar.
|
||||
"""
|
||||
|
||||
# The input needs to be a string:
|
||||
if not isinstance(target, str): return None
|
||||
|
||||
# Pick a scoring mechanism based on whether, or not, partial matches are allowed:
|
||||
if partial: scorer = fuzz.partial_token_sort_ratio
|
||||
else: scorer = fuzz.ratio
|
||||
|
||||
# Match the target against all the options:
|
||||
result = process.extract(
|
||||
target,
|
||||
choices,
|
||||
limit = len(choices),
|
||||
scorer = scorer
|
||||
)
|
||||
|
||||
# Done here:
|
||||
result = [{"choice": r[0], "score": r[1] / 100.0} for r in result]
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def match(
|
||||
targets: List[str],
|
||||
choices: List[str],
|
||||
threshold: float = 0.7,
|
||||
partial: bool = False,
|
||||
allow_null: bool = False
|
||||
) -> dict | None:
|
||||
|
||||
"""
|
||||
Find the best matches for each target from the list of choices such that each choice is used not more than once.
|
||||
USE CASE: When you have a set of columns in a spreadsheet (choices) and you need to match them against a set of
|
||||
expected values (targets). In such a case, if you absolutely need a match for every expected column
|
||||
(target), you must set 'allow_null' to False. This could be useful in cases like bank statements.
|
||||
:param targets: The list of string whose best matches must be found.
|
||||
:param choices: The set of option strings that must be paired to the targets.
|
||||
:param threshold: The absolute lowest similarity value to consider a match. Ranges from 0 to 1.
|
||||
:param partial: If partial string matches are allowed.
|
||||
:param allow_null: If set to False, each target must have a match and even one non-match will void the whole process
|
||||
and return None. If set to True targets need not have matches.
|
||||
:return: A dictionary of the best match for each target from the list of choices such that each choice is used just
|
||||
once.
|
||||
"""
|
||||
|
||||
# Start with basic variables:
|
||||
all_rankings = []
|
||||
all_matches = {target: None for target in targets}
|
||||
|
||||
# Match every choice against every target:
|
||||
for target in targets:
|
||||
rankings_for_target = rank(target, choices, partial = partial) or []
|
||||
for r in rankings_for_target: r["target"] = target
|
||||
all_rankings += rankings_for_target
|
||||
|
||||
# Sort everything based entirely on the final scores of similarity
|
||||
# from most to least similar (descending order):
|
||||
all_rankings = sorted(all_rankings, key = lambda x: x["score"], reverse = True)
|
||||
|
||||
# Now pick the target of the absolute best match,
|
||||
# and keep eliminating the best match for each target:
|
||||
while len(all_rankings) > 0:
|
||||
top_ranking = all_rankings[0]
|
||||
if top_ranking["score"] > threshold:
|
||||
all_matches[top_ranking["target"]] = top_ranking["choice"]
|
||||
elif not allow_null:
|
||||
all_matches = None
|
||||
break
|
||||
all_rankings = [r for r in all_rankings if r["choice"] != top_ranking["choice"]]
|
||||
|
||||
# Done here:
|
||||
return all_matches
|
||||
|
||||
|
||||
# *****************************************************************************************************************
|
||||
# ***** ****
|
||||
# *** MAIN PROGRAM ***
|
||||
# ***** ****
|
||||
# *****************************************************************************************************************
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
pass
|
||||
Reference in New Issue
Block a user