Download panphon/featuretable.py from MLSpeech/FALCON: direct link, hf CLI and curl.
- Browser
- Download file 22.8 kB
-
https://huggingface.co/spaces/MLSpeech/FALCON/resolve/main/panphon/featuretable.py
- Command line
-
hf download hf://spaces/MLSpeech/FALCON/panphon/featuretable.py
-
curl -L -o featuretable.py https://huggingface.co/spaces/MLSpeech/FALCON/resolve/main/panphon/featuretable.py
22.8 kB
| # -*- coding: utf-8 -*- | |
| from __future__ import annotations | |
| from typing import Any, Pattern | |
| import os.path | |
| import unicodedata | |
| import collections | |
| import numpy | |
| import pkg_resources | |
| import regex as re | |
| import csv | |
| from . import xsampa | |
| from .segment import Segment | |
| from functools import reduce | |
| feature_sets = { | |
| 'spe+': (os.path.join('data', 'ipa_all.csv'), | |
| os.path.join('data', 'feature_weights.csv')) | |
| } | |
| class SegmentSorter: | |
| def __init__(self, segments,): | |
| self._segments = segments | |
| self._sorted=False | |
| def segments(self): | |
| if not self._sorted: | |
| self.sort_segments() | |
| return self._segments | |
| def sort_segments(self): | |
| self.segments.sort(key=self.segment_key) | |
| def segment_key(segment_tuple): | |
| segment_data=segment_tuple[1] | |
| return ( | |
| segment_data['syl'], segment_data['son'], segment_data['cons'], segment_data['cont'], | |
| segment_data['delrel'], segment_data['lat'], segment_data['nas'], segment_data['strid'], | |
| segment_data['voi'], segment_data['sg'], segment_data['cg'], segment_data['ant'], | |
| segment_data['cor'], segment_data['distr'], segment_data['lab'], segment_data['hi'], | |
| segment_data['lo'], segment_data['back'], segment_data['round'], segment_data['velaric'], | |
| segment_data['tense'], segment_data['long'], segment_data['hitone'], segment_data['hireg'] | |
| ) | |
| class FeatureTable(object): | |
| """The basic PanPhon object for representing the features of sets of segments. | |
| :param feature_set str: The set of fetures to be used by the FeatureTable object. | |
| """ | |
| TRIE_LEAF_MARKER = None | |
| def __init__(self, feature_set: str='spe+'): | |
| bases_fn, weights_fn = feature_sets[feature_set] | |
| self.weights = self._read_weights(weights_fn) | |
| self.segments, self.seg_dict, self.names = self._read_bases(bases_fn, self.weights) | |
| self.seg_regex = self._build_seg_regex() | |
| self.seg_trie = self._build_seg_trie() | |
| self.longest_seg = max([len(x) for x in self.seg_dict.keys()]) | |
| self.xsampa = xsampa.XSampa() | |
| self.sorted_segments = SegmentSorter(self.segments) #used for quick binary searches | |
| def normalize(data: str) -> str: | |
| return unicodedata.normalize('NFD', data) | |
| def _read_bases(self, fn: str, weights): | |
| fn = pkg_resources.resource_filename(__name__, fn) | |
| segments = [] | |
| with open(fn) as f: | |
| reader = csv.reader(f) | |
| header = next(reader) | |
| names = header[1:] | |
| for row in reader: | |
| ipa = FeatureTable.normalize(row[0]) | |
| vals = [{'-': -1, '0': 0, '+': 1}[x] for x in row[1:]] | |
| vec = Segment(names, | |
| {n: v for (n, v) in zip(names, vals)}, | |
| weights=weights) | |
| segments.append((ipa, vec)) | |
| seg_dict = dict(segments) | |
| return segments, seg_dict, names | |
| def _read_weights(self, weights_fn: str) -> list[float]: | |
| weights_fn = pkg_resources.resource_filename(__name__, weights_fn) | |
| with open(weights_fn) as f: | |
| reader = csv.reader(f) | |
| next(reader) | |
| weights = [float(x) for x in next(reader)] | |
| return weights | |
| def _build_seg_regex(self) -> re.Pattern: | |
| segs = sorted(self.seg_dict.keys(), key=lambda x: len(x), reverse=True) | |
| return re.compile(r'(?P<all>{})'.format('|'.join(segs))) | |
| def _build_seg_trie(self) -> dict: | |
| trie = {} | |
| for seg in self.seg_dict.keys(): | |
| node = trie | |
| for char in seg: | |
| if char not in node: | |
| node[char] = {} | |
| node = node[char] | |
| node[self.TRIE_LEAF_MARKER] = None | |
| return trie | |
| def fts(self, ipa: str, normalize: bool=True) -> dict[str, int]: | |
| if normalize: | |
| ipa = FeatureTable.normalize(ipa) | |
| if ipa in self.seg_dict: | |
| return self.seg_dict[ipa] | |
| else: | |
| return {} | |
| def longest_one_seg_prefix(self, word: str, normalize: bool=True) -> str: | |
| """Return longest Unicode IPA prefix of a word | |
| Args: | |
| word (unicode): input word as Unicode IPA string | |
| normalize (bool): whether the word should be pre-normalized | |
| Returns: | |
| unicode: longest single-segment prefix of `word` in database | |
| """ | |
| if normalize: | |
| word = FeatureTable.normalize(word) | |
| last_found_length = 0 | |
| node = self.seg_trie | |
| for pos in range(len(word) + 1): | |
| if pos == len(word) or word[pos] not in node: | |
| return word[:last_found_length] | |
| node = node[word[pos]] | |
| if self.TRIE_LEAF_MARKER in node: | |
| last_found_length = pos + 1 | |
| return '' | |
| def ipa_segs(self, word: str, normalize: bool=True) -> list[str]: | |
| """Returns a list of segments from a word | |
| Args: | |
| word (unicode): input word as Unicode IPA string | |
| normalize (bool): whether to pre-normalize the word | |
| Returns: | |
| list: list of strings corresponding to segments found in `word` | |
| """ | |
| if normalize: | |
| word = FeatureTable.normalize(word) | |
| return self._segs(word, include_invalid=False, normalize=normalize) | |
| def validate_word(self, word: str, normalize: bool=True): | |
| """Returns True if `word` consists exhaustively of valid IPA segments | |
| Args: | |
| word (unicode): input word as Unicode IPA string | |
| normalize (bool): whether to pre-normalize the word | |
| Returns: | |
| bool: True if `word` can be divided exhaustively into IPA segments | |
| that exist in the database | |
| """ | |
| return not self._segs(word, include_valid=False, include_invalid=True, normalize=normalize) | |
| def word_fts(self, word: str, normalize: bool=True): | |
| """Return a list of Segment objects corresponding to the segments in | |
| word. | |
| Args: | |
| word (unicode): word consisting of IPA segments | |
| normalize (bool): whether to pre-normalize the word | |
| Returns: | |
| list: list of Segment objects corresponding to word | |
| """ | |
| return [self.fts(ipa, False) for ipa in self.ipa_segs(word, normalize)] | |
| def word_array(self, ft_names: list[str], word: str, normalize: bool=True) -> numpy.ndarray: | |
| """Return a ndarray of features namd in ft_name for the segments in word | |
| Args: | |
| ft_names (list): strings naming subset of features in self.names | |
| word (unicode): word to be analyzed | |
| normalize (bool): whether to pre-normalize the word | |
| Returns: | |
| ndarray: segments in rows, features in columns as [-1, 0, 1] | |
| """ | |
| return numpy.array([s.numeric(ft_names) for s in self.word_fts(word, normalize)]) | |
| def bag_of_features(self, word: str, normalize: bool=True) -> numpy.ndarray: | |
| """Return a vector in which each dimension is the number of times a feature-value pair occurs in the word | |
| Args: | |
| word (unicode): word consisting of IPA segments | |
| normalize (bool): whether to pre-normalize the word | |
| Returns: | |
| array: array of integers corresponding to a bag of feature-value pair counts | |
| """ | |
| # we changed here !! | |
| # word_features = self.word_fts(word: str, normalize: bool=True) | |
| word_features = self.word_fts(word, normalize=True) | |
| features = [v + f for f in self.names for v in ['+', '0', '-']] | |
| bag = collections.OrderedDict() | |
| for f in features: | |
| bag[f] = 0 | |
| vdict = {-1: '-', 0: '0', 1: '+'} | |
| for w in word_features: | |
| for (f, v) in w.items(): | |
| bag[vdict[v] + f] += 1 | |
| return numpy.array(list(bag.values())) | |
| def seg_known(self, segment: str, normalize: bool=True) -> bool: | |
| """Return True if `segment` is in segment <=> features database | |
| Args: | |
| segment (unicode): consonant or vowel | |
| normalize (bool): whether to pre-normalize the segment | |
| Returns: | |
| bool: True, if `segment` is in the database | |
| """ | |
| if normalize: | |
| segment = FeatureTable.normalize(segment) | |
| return segment in self.seg_dict | |
| def segs_safe(self, word: str, normalize: bool=True): | |
| """Return a list of segments (as strings) from a word | |
| Characters that are not valid segments are included in the list as | |
| individual characters. | |
| Args: | |
| word (unicode): word as an IPA string | |
| normalize (bool): whether to pre-normalize the word | |
| Returns: | |
| list: list of Unicode IPA strings corresponding to segments in | |
| `word` | |
| """ | |
| if normalize: | |
| word = FeatureTable.normalize(word) | |
| return self._segs(word, include_invalid=True, normalize=normalize) | |
| def _segs(self, word: str, *, include_valid: bool=True, include_invalid: bool, normalize: bool=True) -> list[str]: | |
| if normalize: | |
| word = FeatureTable.normalize(word) | |
| segs = [] | |
| while word: | |
| m = self.longest_one_seg_prefix(word, False) | |
| if m: | |
| if include_valid: | |
| segs.append(m) | |
| word = word[len(m):] | |
| else: | |
| if include_invalid: | |
| segs.append(word[0]) | |
| word = word[1:] | |
| return segs | |
| def filter_segs(self, segs: list[str], normalize: bool=True) -> list[str]: | |
| """Given list of strings, return only those which are valid segments | |
| Args: | |
| segs (list): list of IPA Unicode strings | |
| normalize (bool): whether to pre-normalize the segments | |
| Return: | |
| list: list of IPA Unicode strings identical to `segs` but with | |
| invalid segments filtered out | |
| """ | |
| return list(filter(lambda seg: self.seg_known(seg, normalize), segs)) | |
| def filter_string(self, word: str, normalize: bool=True) -> str: | |
| """Return a string like the input but containing only legal IPA segments | |
| Args: | |
| word (unicode): input string to be filtered | |
| normalize (bool): whether to pre-normalize the word (and return a normalized string) | |
| Returns: | |
| unicode: string identical to `word` but with invalid IPA segments | |
| absent | |
| """ | |
| return ''.join(self.ipa_segs(word, normalize)) | |
| def fts_intersection(self, segs: list[str], normalize: bool=True) -> Segment: | |
| """Return a Segment object containing the features shared by all segments | |
| Args: | |
| segs (list): IPA segments | |
| normalize (bool): whether to pre-normalize the segments | |
| Returns: | |
| Segment: the features shared by all segments in segs | |
| """ | |
| return reduce(lambda a, b: a & b, | |
| [self.fts(s, normalize) for s in self.filter_segs(segs, normalize)]) | |
| def fts_match_all(self, fts: dict[str, int], inv: list[str], normalize: bool=True) -> bool: | |
| """Return `True` if all segments in `inv` matches the features in fts | |
| Args: | |
| fts (dict): a dictionary of features | |
| inv (list): a collection of IPA segments represented as Unicode | |
| strings | |
| normalize (bool): whether to pre-normalize the segments | |
| Returns: | |
| bool: `True` if all segments in `inv` match the features in `fts` | |
| """ | |
| return all([self.fts(s, normalize) >= fts for s in inv]) | |
| def fts_match_any(self, fts: dict[str, int], inv: list[str], normalize: bool=True) -> bool: | |
| """Return `True` if any segments in `inv` matches the features in fts | |
| Args: | |
| fts (dict): a dictionary of features | |
| inv (list): a collection of IPA segments represented as Unicode | |
| strings | |
| normalize (bool): whether to pre-normalize the segments | |
| Returns: | |
| bool: `True` if any segments in `inv` matches the features in `fts` | |
| """ | |
| return any([self.fts(s, normalize) >= fts for s in inv]) | |
| def fts_contrast(self, fs: dict[str, int], ft_name: str, inv: list[str], normalize: bool=True) -> bool: | |
| """Return `True` if there is a segment in `inv` that contrasts in feature | |
| `ft_name`. | |
| Args: | |
| fs (dict): feature specifications used to filter `inv`. | |
| ft_name (str): name of the feature where contrast must be present. | |
| inv (list): collection of segments represented as Unicode strings. | |
| normalize (bool): whether to pre-normalize the segments | |
| Returns: | |
| bool: `True` if two segments in `inv` are identical in features except | |
| for feature `ft_name` | |
| """ | |
| inv_segs = filter(lambda x: x >= fs, map(lambda seg: self.fts(seg, normalize), inv)) | |
| for a in inv_segs: | |
| for b in inv_segs: | |
| if a != b: | |
| if a.differing_specs(b) == [ft_name]: | |
| return True | |
| return False | |
| def fts_count(self, fts: dict[str, int], inv: list[str], normalize: bool=True) -> int: | |
| """Return the count of segments in an inventory matching a given | |
| feature mask. | |
| Args: | |
| fts (dict): feature mask given as a set of (value, feature) tuples | |
| inv (list): inventory of segments (as Unicode IPA strings) | |
| normalize (bool): whether to pre-normalize the segments | |
| Returns: | |
| int: number of segments in `inv` that match feature mask `fts` | |
| """ | |
| return len(list(filter(lambda s: self.fts(s, normalize) >= fts, inv))) | |
| def match_pattern(self, pat: list[str], word: str, normalize: bool=True) -> list[dict[str, int]]: | |
| """Implements fixed-width pattern matching. | |
| Matches just in case pattern is the same length (in segments) as the | |
| word and each of the segments in the pattern is a featural subset of the | |
| corresponding segment in the word. Matches return the corresponding list | |
| of feature sets; failed matches return None. | |
| Args: | |
| pat (list): pattern consisting of a sequence of feature dicts | |
| word (unicode): a Unicode IPA string consisting of zero or more | |
| segments | |
| normalize (bool): whether to pre-normalize the word | |
| Returns: | |
| list: corresponding list of feature dicts or, if there is no match, | |
| None | |
| """ | |
| segs = self.word_fts(word, normalize) | |
| if len(pat) != len(segs): | |
| return None | |
| else: | |
| if all([s >= p for (s, p) in zip(segs, pat)]): | |
| return segs | |
| def match_pattern_seq(self, pat, const, normalize=True): | |
| """Implements limited pattern matching. Matches just in case pattern is | |
| the same length (in segments) as the constituent and each of the | |
| segments in the pattern is a featural subset of the corresponding | |
| segment in the word. | |
| Args: | |
| pat (list): pattern consisting of a list of feature dicts, e.g. | |
| [{'voi': 1}] | |
| const (list): a sequence of Unicode IPA strings consisting of zero | |
| or more segments. | |
| normalize (bool): whether to pre-normalize the segments | |
| Returns: | |
| bool: `True` if `const` matches `pat` | |
| """ | |
| segs = [self.fts(s, normalize) for s in const] | |
| if len(pat) != len(segs): | |
| return False | |
| else: | |
| return all([s >= p for (s, p) in zip(segs, pat)]) | |
| def all_segs_matching_fts(self, ft_mask): | |
| """Return segments matching a feature mask, a dict of features | |
| Args: | |
| ft_mask (list): feature mask dict, e.g. {'voi': -1, 'cont': 1}. | |
| Returns: | |
| list: segments matching `ft_mask`, sorted in reverse order by length | |
| """ | |
| matching_segs = [ipa for (ipa, fts) in self.segments if fts >= ft_mask] | |
| return sorted(matching_segs, key=lambda x: len(x), reverse=True) | |
| def compile_regex_from_str(self, pat): | |
| """Given a string describing features masks for a sequence of segments, | |
| return a compiled regex matching the corresponding strings. | |
| Args: | |
| pat (str): feature masks, each enclosed in square brackets, in | |
| which the features are delimited by any standard delimiter. | |
| Returns: | |
| Pattern: regular expression pattern equivalent to `pat` | |
| """ | |
| s2n = {'-': -1, '0': 0, '+': 1} | |
| seg_res = [] | |
| for mat in re.findall(r'\[[^]]+\]+', pat): | |
| ft_mask = {k: s2n[v] for (v, k) in re.findall(r'([+-])(\w+)', mat)} | |
| segs = self.all_segs_matching_fts(ft_mask) | |
| seg_res.append('({})'.format('|'.join(segs))) | |
| regexp = ''.join(seg_res) | |
| return re.compile(regexp) | |
| def segment_to_vector(self, seg, normalize=True): | |
| """Given a Unicode IPA segment, return a list of feature specificiations | |
| in canonical order. | |
| Args: | |
| seg (unicode): IPA consonant or vowel | |
| normalize: whether to pre-normalize the segment | |
| Returns: | |
| list: feature specifications ('+'/'-'/'0') in the order from | |
| `FeatureTable.names` | |
| """ | |
| return self.fts(seg, normalize).strings() | |
| def standardize_tones(self, word, nonstandard_tones=['¹','²','³','⁴','⁵']): | |
| standard_tones = ['˩', '˨', '˧', '˦', '˥'] | |
| tone_map = dict(zip(nonstandard_tones, standard_tones)) | |
| standardized_word = ''.join(tone_map.get(char, char) for char in word) | |
| return standardized_word | |
| def word_to_vector_list(self, word, numeric=False, xsampa=False, nonstandard_tones=['¹','²','³','⁴','⁵'], normalize=True): | |
| """Return a list of feature vectors, given a Unicode IPA word. | |
| Args: | |
| word (unicode): string in IPA (or X-SAMPA, provided `xsampa` is True) | |
| numeric (bool): if True, return features as numeric values instead | |
| of strings | |
| xsampa (bool): whether the word is in X-SAMPA instead of IPA | |
| normalize: whether to pre-normalize the word (applies to IPA only) | |
| nonstandard_tones (list): list of 5 nonstandard tones to be conveted | |
| to IPA tone markers. | |
| The order and numbering of the tones can be changed to reflect data. | |
| Returns: | |
| list: a list of lists of '+'/'-'/'0' or 1/-1/0 | |
| """ | |
| if xsampa: | |
| word = self.xsampa.convert(word) | |
| if nonstandard_tones: | |
| word=self.standardize_tones(word,nonstandard_tones) | |
| segs = self.word_fts(word, normalize or xsampa) | |
| if numeric: | |
| tensor = [x.numeric() for x in segs] | |
| else: | |
| tensor = [x.strings() for x in segs] | |
| return tensor | |
| def _compare_vectors(self,vector1, vector2): | |
| """Compare two feature vectors digit by digit. | |
| Args: | |
| vector1 (list): First vector to compare. | |
| vector2 (list): Second vector to compare. | |
| Returns: | |
| int: -1 if vector1 < vector2, 1 if vector1 > vector2, 0 if they are equal. | |
| """ | |
| for v1, v2 in zip(vector1, vector2): | |
| if v1 < v2: | |
| return -1 | |
| elif v1 > v2: | |
| return 1 | |
| return 0 # Vectors are equal | |
| def _binary_search(self, segment_list, target, fuzzy_search=False): | |
| """Binary search to find the segment matching the target vector. | |
| Args: | |
| segment_list (list): List of segments where each segment is a tuple (IPA, feature vector). | |
| target (list): Target feature vector to search for. | |
| fuzzy_search (bool): whether to search for the closest vector match if an exact match is not found. | |
| If disabled and an exact match is not found, a None value is returned. | |
| Returns: | |
| str: The IPA segment matching the target vector, or None if not found. | |
| """ | |
| low, high = 0, len(segment_list) - 1 | |
| best_match_index = None | |
| while low <= high: | |
| mid = (low + high) // 2 | |
| word_vec = self.sorted_segments.segment_key(segment_list[mid]) | |
| comparison = self._compare_vectors(word_vec, target) | |
| if comparison == 0: | |
| best_match_index = mid | |
| break | |
| elif comparison < 0: | |
| low = mid + 1 | |
| else: | |
| high = mid - 1 | |
| if best_match_index is None and fuzzy_search: | |
| # Used for fuzzy searching | |
| best_match_index = mid | |
| if best_match_index is not None: | |
| # Check neighboring rows within the range of +-5 | |
| best_match = segment_list[best_match_index] | |
| for offset in range(-9, 5): | |
| neighbor_index = best_match_index + offset | |
| if 0 <= neighbor_index < len(segment_list): | |
| neighbor_segment = segment_list[neighbor_index] | |
| if not self._compare_vectors(self.sorted_segments.segment_key(neighbor_segment),target): | |
| # Check if the neighbor segment has a shorter name | |
| if len(neighbor_segment[0]) < len(best_match[0]): | |
| best_match = neighbor_segment | |
| return best_match[0] | |
| return None | |
| def vector_list_to_word(self, tensor, xsampa=False,fuzzy_search=False): | |
| """Return a Unicode IPA word, given a list of feature vectors. | |
| Args: | |
| tensor (list): a list of lists of '+'/'-'/'0' or 1/-1/0 | |
| xsampa (bool): whether to return the word in X-SAMPA instead of IPA | |
| fuzzy_search (bool): whether to search for the closest vector match if an exact match is not found. | |
| If disabled and an exact match is not found, a `ValueError` is raised. | |
| Returns: | |
| unicode: string in IPA (or X-SAMPA, provided `xsampa` is True) | |
| """ | |
| word = "" | |
| for vector in tensor: | |
| match = self._binary_search(self.sorted_segments.segments, vector, fuzzy_search) | |
| if match: | |
| word += match | |
| else: | |
| raise ValueError(f"No matching segment found for vector: {vector}") | |
| if xsampa: | |
| word = self.xsampa.convert(word) | |
| return word | |