File size: 8,105 Bytes
4ba3b33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
""" 
Problem: 
Nt3awnou's platform collects raw data filled manually by users (people in need). 
Among this data is the user's localisation.
The localisation is a text input that is not standardized: 
 i.e. a user can input a single or multiple locations 
 (either douars/provinces/communes/regions or all combined), 
 in arabic or latin, with misspellings etc. 
This doesn't help in visualization or in statistics
where localisations can be redundant because they were written in different manners.

Examples
```
دوار تجكَالت
ابرداتن ازكور
خزامة
Tansgharte
دوار امندار
Douar Essour Tidrara Aghwatim Tahnaouet Al Haouz
دوار تكاديرت
Douar Essour tidrara- aghouatine- Tahanaout-El Haouz
```
Solution:
We collected a reference dataset that contains all douar names (arabic and latin)
with their corresponding regions, communes and provinces.
We developed methods using fuzzy matching and phonetics
to map the user's localisation to the closest match in the reference dataset

"""

from typing import Tuple
from pyphonetics import RefinedSoundex, Metaphone
import math
import difflib
import re


EPICENTER_LOCATION = [31.12210171476489, -8.42945837915193]
certainty_threshold = 1


def extract_ngrams(text, n):
    """ 
    A function that returns a list of n-grams from a text
    """
    ngrams = []

    if n < 1 or n > len(text):
        return ngrams  # Return an empty list if n is invalid

    # Iterate through the text and extract n-grams
    for i in range(len(text) - n + 1):
        ngram = text[i:i + n]
        ngrams.append(' '.join(ngram))

    return ngrams


def get_phonetics_distance(w1, w2):
    """
    A function that calculates levenhstein distance between phonetics
    representation of two words: add error term to the score
    """
    rs = RefinedSoundex()
    mt = Metaphone()
    d1 = mt.distance(w1, w2, metric='levenshtein')
    d2 = rs.distance(w1, w2, metric='levenshtein')
    res = (d1 + d2) / 2 + 0.05
    return res 


def get_top_n_phonetics_matches(
    w: str, ref_words: list, threshold=1, top_n=1) -> list[Tuple]:
  """
  A function that returns the top_n closest words to w from ref_words
  for which distance <= threshold
  using phonetical representation
  """
  if not w:
    return list()
  distances = {x: get_phonetics_distance(w, x) for x in ref_words}
  selected_words = {x: d for x, d in distances.items() if d<=threshold}
  sorted_d = dict(sorted(selected_words.items(), key=lambda item: item[1]))

  return list(sorted_d.items())[:top_n]


def get_geometric_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
    """ 
    A function that returns the distance between two points on earth
    using the haversine formula
    """
    dlon = math.radians(lon2 - lon1)
    dlat = math.radians(lat2 - lat1)
    a0 = (math.sin(dlat / 2)) ** 2 + math.cos(math.radians(lat1))
    a = a0 * math.cos(math.radians(lat2)) * (math.sin(dlon / 2)) ** 2
    c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
    distance = 6371 * c
    return distance


def are_village_names_similar(village_a: str, village_b: str) -> float:
    """ 
    A function that returns True if the two villages 
    are similar using strict fuzzy matching
    """
    if difflib.SequenceMatcher(None, village_a, village_b).ratio() >= 0.90:
        return True
    return False


def get_uncertainty_range(input_dict: dict, threshold: float) -> list:
    """ 
    A function that returns a list of tuples of the closest matches
    """
    if len(input_dict)<=1:
        return input_dict

    # sort by distance
    sorted_items = sorted(input_dict.items(), key=lambda item: item[1][1])
    data = {key: value for key, value in sorted_items}

    # Iterate through the keys in the dictionary
    keys = list(data.keys())
    min_key = keys[0]
    min_value = data[min_key][1]

    # Initialize a list to store the result tuples
    result = {f"{min_key}":data[min_key]}

    for j in range(1, len(keys)):
        key2 = keys[j]
        value2 = data[key2][1]

        # Calculate the absolute difference between the float values
        difference = abs(min_value - value2)

        # If the difference is less than the threshold, add the tuple to the result
        if difference <= threshold:
            result[key2] = data[key2]
        else:
            break

    return result


def match_word(w, ref_dict, select_one_match=False):
  """ 
  A function that returns the closest match of w from ref_dict
  using phonetical representation and fuzzy matching
  """
  w = w.strip().upper()

  if len(w)==0:
    return {}

  else:
    closest_ref_w = dict()
    use_phonetics = True

    for category, names in ref_dict.items():
      # check exact matching
      if w in names:
        use_phonetics = False
        closest_ref_w[category] = (w, 0)
        break

      # check textual similarity (fuzzy matching)
      sim = list(map(lambda x:are_village_names_similar(w,x), names))
      similar_names = [names[i] for i in range(len(names)) if sim[i]==True]
      if similar_names:
        use_phonetics = False
        closest_ref_w[category] = (similar_names[0], 0.01) if select_one_match else list(map(lambda x:(x, 0.01), similar_names))

      # if no similar name was found check phonetical similarity
      else:
        res = get_top_n_phonetics_matches(w, names, threshold=2, top_n=1)
        if res:
          closest_ref_w[category] = res[0] # get closest match

    if closest_ref_w and use_phonetics:
      if not select_one_match:
        closest_ref_w = get_uncertainty_range(closest_ref_w, certainty_threshold)
      else:
        k, v = min(closest_ref_w.items(), key=lambda x: x[1][1])
        closest_ref_w = {k: v}

  return closest_ref_w


def parse_and_map_localisation(text: str, ref_dict: dict, select_one_match: bool=True):
  """ 
  A function that parses text containing users localisation
  and returns the closest matches per categoty from ref_dict
  Example: 
    input = COMMUNE MZODA : DOUARS : TOUKHRIBIN –TLAKEMT - COMMUNE IMINDOUNITE : DOUAR AZARZO
    output = {'commune_fr': ('IMINDOUNIT', 0.01), 'nom_fr': ('TOUKHRIBINE', 0.01)}
  """
  toxic = r"\bدوار|مصلى|\(|\)|douars?|communes?|cercles?|provinces?|villes?|regions?|caidate?|and|جماعة|\b|:|-|\d"
  text = re.sub(toxic, '', text.lower())
  regex_pattern = r"\|| |\.|,|/|et |و "
  tokens = re.split(regex_pattern, text.replace('-', ' '))
  filtered_tokens = [s for s in tokens if s.strip() != '']

  ngrams_mapping = {}

  for n in range(1, len(filtered_tokens)+1):

    # generate ngrams
    ngrams = extract_ngrams(filtered_tokens, n)

    # init dict with ngram mapping
    mapping_ngram = {}

    # generate a mapping for the ngram with argmin matches
    for tok in ngrams:
      res = match_word(tok, ref_dict, select_one_match=select_one_match)
      if not res:
        continue

      min_k, min_v = min(res.items(), key=lambda x:x[1][1])

      # if min_k in previous tokens, then choose the min, else add it to mapping
      if min_k in mapping_ngram:
        saved_match, saved_distance = mapping_ngram[min_k]

        if saved_distance > min_v[1]:
          mapping_ngram[min_k] = min_v

        else:
          continue

      else:
        mapping_ngram[min_k] = min_v

    ngrams_mapping[n] = mapping_ngram


  # first squeeze dict s.t. one match remains per category
  categories = ref_dict.keys()
  result = {}
  for _, inner_dict in ngrams_mapping.items():
    for k in categories:
        # Check if the key exists in the inner dictionary
        if k in inner_dict:
          current_match, current_val = inner_dict[k]
          if k in result:
            previous_match, previous_val = result[k]
            if current_val < previous_val:
              result[k] = (current_match, current_val)
          else:
            result[k] = (current_match, current_val)

  # then, discard matches with a high distance from min (set 0.5+min_d as threshold)
  thresh = min(result.values(), key=lambda x:x[1])[1] + 0.5
  output = {k: v_d for k, v_d in result.items() if v_d[1]<=thresh}

  return output