Repository: dedupeio/dedupe

Issue:
Inconsistent alphaNumericPredicate behavior
Hi, I believe `alphaNumericPredicate` implementation is inconsistent.

Example:
```
In [1]: from dedupe.predicates import alpha_numeric

In [2]: alpha_numeric("a_1")
Out[2]: ['a', '1']

In [3]: alpha_numeric("a 1")
Out[3]: ['1']

In [4]: alpha_numeric("a")
Out[4]: []

In [5]: alpha_numeric("1")
Out[5]: ['1']
```

Should it really match `'a'` for `'a_1'` input, if it doesn't match it for `'a 1'` or just `'a'`?

Discussion:

Patch:
diff --git a/dedupe/api.py b/dedupe/api.py
index 84d6acc..ce71fad 100644
--- a/dedupe/api.py
+++ b/dedupe/api.py
@@ -130,6 +130,9 @@ class DedupeMatching(IntegralMatching):
 
     """
 
+    def __init__(self, *args, **kwargs) -> None:
+        super().__init__(*args, **kwargs)
+
     def partition(
         self, data: Data, threshold: float = 0.5
     ) -> Clusters:  # pragma: no cover
@@ -1029,9 +1032,6 @@ class ActiveMatching(Matching):
     Class for training a matcher.
     """
 
-    active_learner: labeler.DisagreementLearner | None
-    training_pairs: TrainingData
-
     def __init__(
         self,
         variable_definition: Collection[VariableDefinition],
@@ -1066,7 +1066,10 @@ class ActiveMatching(Matching):
         super().__init__(num_cores, in_memory, **kwargs)
 
         self.data_model = datamodel.DataModel(variable_definition)
+
+        self.training_pairs: TrainingData
         self.training_pairs = {"distinct": [], "match": []}
+        self.active_learner: labeler.DisagreementLearner | None
         self.classifier = sklearn.model_selection.GridSearchCV(
             estimator=sklearn.linear_model.LogisticRegression(),
             param_grid={"C": [0.00001, 0.0001, 0.001, 0.01, 0.1, 1, 10]},
@@ -1298,6 +1301,7 @@ class Dedupe(ActiveMatching, DedupeMatching):
     entity.
     """
 
+    canopies = True
     ActiveLearner = labeler.DedupeDisagreementLearner
 
     def prepare_training(
@@ -1344,6 +1348,8 @@ class Dedupe(ActiveMatching, DedupeMatching):
         self.active_learner = self.ActiveLearner(
             self.data_model,
             data,
+            blocked_proportion,
+            sample_size,
             index_include=examples,
         )
 
@@ -1361,6 +1367,7 @@ class Link(ActiveMatching):
     Mixin Class for Active Learning Record Linkage
     """
 
+    canopies = False
     ActiveLearner = labeler.RecordLinkDisagreementLearner
 
     def prepare_training(
@@ -1414,6 +1421,8 @@ class Link(ActiveMatching):
             self.data_model,
             data_1,
             data_2,
+            blocked_proportion,
+            sample_size,
             index_include=examples,
         )
 
diff --git a/dedupe/datamodel.py b/dedupe/datamodel.py
index 3be472a..69cc63f 100644
--- a/dedupe/datamodel.py
+++ b/dedupe/datamodel.py
@@ -69,17 +69,20 @@ class DataModel(object):
             yield (var.field, comparator, start, stop)
             start = stop
 
-    def predicates(self, canopies: bool = True) -> set[Predicate]:
+    def predicates(
+        self, index_predicates: bool = True, canopies: bool = True
+    ) -> set[Predicate]:
         predicates = set()
         for var in self.primary_variables:
             for predicate in var.predicates:
                 if hasattr(predicate, "index"):
-                    if hasattr(predicate, "canopy"):
-                        if canopies:
-                            predicates.add(predicate)
-                    else:
-                        if not canopies:
-                            predicates.add(predicate)
+                    if index_predicates:
+                        if hasattr(predicate, "canopy"):
+                            if canopies:
+                                predicates.add(predicate)
+                        else:
+                            if not canopies:
+                                predicates.add(predicate)
                 else:
                     predicates.add(predicate)
 
diff --git a/dedupe/labeler.py b/dedupe/labeler.py
index 734dfd8..6637cac 100644
--- a/dedupe/labeler.py
+++ b/dedupe/labeler.py
@@ -237,8 +237,8 @@ class DedupeBlockLearner(BlockLearner):
         N_SAMPLED_RECORDS = 5000
         N_SAMPLED_RECORD_PAIRS = 10000
 
-        index_data = sample_records(data, 50000)
-        sampled_records = sample_records(index_data, N_SAMPLED_RECORDS)
+        index_data = Sample(data, 50000)
+        sampled_records = Sample(index_data, N_SAMPLED_RECORDS)
         preds = self.data_model.predicates()
 
         self.block_learner = training.DedupeBlockLearner(
@@ -289,9 +289,9 @@ class RecordLinkBlockLearner(BlockLearner):
         N_SAMPLED_RECORDS = 1000
         N_SAMPLED_RECORD_PAIRS = 5000
 
-        sampled_records_1 = sample_records(data_1, N_SAMPLED_RECORDS)
-        index_data = sample_records(data_2, 50000)
-        sampled_records_2 = sample_records(index_data, N_SAMPLED_RECORDS)
+        sampled_records_1 = Sample(data_1, N_SAMPLED_RECORDS)
+        index_data = Sample(data_2, 50000)
+        sampled_records_2 = Sample(index_data, N_SAMPLED_RECORDS)
 
         preds = self.data_model.predicates(canopies=False)
 
@@ -431,6 +431,8 @@ class DedupeDisagreementLearner(DisagreementLearner):
         self,
         data_model: DataModel,
         data: Data,
+        blocked_proportion: float,
+        sample_size: int,
         index_include: TrainingExamples,
     ):
 
@@ -467,6 +469,8 @@ class RecordLinkDisagreementLearner(DisagreementLearner):
         data_model: DataModel,
         data_1: Data,
         data_2: Data,
+        blocked_proportion: float,
+        sample_size: int,
         index_include: TrainingExamples,
     ):
 
@@ -499,9 +503,10 @@ class RecordLinkDisagreementLearner(DisagreementLearner):
         self.mark(examples, labels)
 
 
-def sample_records(data: Mapping, sample_size: int) -> Mapping:
-    if len(data) <= sample_size:
-        return data
-    else:
-        sample = random.sample(data.keys(), sample_size)
-        return {k: data[k] for k in sample}
+class Sample(dict):  # type: ignore[type-arg]
+    def __init__(self, d: Mapping, sample_size: int):  # type: ignore[type-arg]
+        if len(d) <= sample_size:
+            super().__init__(d)
+        else:
+            sample = random.sample(d.keys(), sample_size)
+            super().__init__({k: d[k] for k in sample})
diff --git a/dedupe/predicates.py b/dedupe/predicates.py
index 288e666..8d76295 100644
--- a/dedupe/predicates.py
+++ b/dedupe/predicates.py
@@ -27,7 +27,7 @@ integers = re.compile(r"\d+").findall
 start_word = re.compile(r"^([\w']+)").match
 two_start_words = re.compile(r"^([\w']+\s+[\w']+)").match
 start_integer = re.compile(r"^(\d+)").match
-alpha_numeric = re.compile(r"(?=\w*\d)[a-zA-Z\d]+").findall
+alpha_numeric = re.compile(r"(?=[a-zA-Z]*\d)[a-zA-Z\d]+").findall
 
 PUNCTABLE = str.maketrans("", "", string.punctuation)
 


Test patch:
diff --git a/tests/test_predicates.py b/tests/test_predicates.py
index 541339d..5c3c1c6 100644
--- a/tests/test_predicates.py
+++ b/tests/test_predicates.py
@@ -68,6 +68,24 @@ class TestLatLongGrid(unittest.TestCase):
         assert block_val == ()
 
 
+class TestAlpaNumeric(unittest.TestCase):
+    def test_alphanumeric(self):
+
+        assert predicates.alphaNumericPredicate("a1") == set(["a1"])
+        assert predicates.alphaNumericPredicate("1a") == set(["1a"])
+        assert predicates.alphaNumericPredicate("a1b") == set(["a1b"])
+        assert predicates.alphaNumericPredicate("1 a") == set(["1"])
+        assert predicates.alphaNumericPredicate("a1 b1") == set(["a1", "b1"])
+        assert predicates.alphaNumericPredicate("asdf") == set()
+        assert predicates.alphaNumericPredicate("1") == set(["1"])
+        assert predicates.alphaNumericPredicate("a_1") == set(["1"])
+        assert predicates.alphaNumericPredicate("a$1") == set(["1"])
+        assert predicates.alphaNumericPredicate("a 1") == set(["1"])
+        assert predicates.alphaNumericPredicate("773-555-1676") == set(
+            ["773", "555", "1676"]
+        )
+
+
 class TestNumericPredicates(unittest.TestCase):
     def test_order_of_magnitude(self):
         assert predicates.orderOfMagnitude(10) == ("1",)