Skip to content
Beyond Coordinates
Go back

Word Segmentation Algorithms and a Simple Implementation, Part 2

Updated:

This article introduces the word-segmentation algorithms mentioned in Word Segmentation Algorithms and a Simple Implementation, Part 1, including but not limited to those used by Jieba.

The Jieba source code referenced below is from version 0.39.

Prefix Dictionary

Jieba builds its prefix dictionary with the following code:

def gen_pfdict(self, f):
    lfreq = {}
    ltotal = 0
    f_name = f.name
    for lineno, line in enumerate(f, 1):
        try:
            line = line.strip().decode('utf-8')
            word, freq = line.split(' ')[:2]
            freq = int(freq)
            lfreq[word] = freq
            ltotal += freq
            for ch in range(len(word)):
                wfrag = word[:ch + 1]
                if wfrag not in lfreq:
                    lfreq[wfrag] = 0
        except ValueError:
            raise ValueError(
                'invalid dictionary entry in %s at Line %s: %s' % (f_name, lineno, line))
    f.close()
    return lfreq, ltotal

The implementation looks almost too straightforward. Some tests suggest that it is slightly faster in Python than a trie, although that still needs verification. The result is likely affected by language characteristics, dictionary size, word length, and the number of distinct characters.

Trie

Jieba’s original trie-construction algorithm, shown below, was replaced by the prefix-dictionary approach above.

def gen_trie(f_name):
    lfreq = {}
    trie = {}
    ltotal = 0.0
    with open(f_name, 'rb') as f:
        lineno = 0
        for line in f.read().rstrip().decode('utf-8').split('\n'):
            lineno += 1
            try:
                word,freq,_ = line.split(' ')
                freq = float(freq)
                lfreq[word] = freq
                ltotal += freq
                p = trie
                for c in word:
                    if c not in p:
                        p[c] ={}
                    p = p[c]
                p['']='' #ending flag
            except ValueError as e:
                logger.debug('%s at line %s %s' % (f_name,  lineno, line))
                raise e
    return trie, lfreq, ltotal

Tries remain useful in many applications, especially search. Here are several related exercises:

Directed Acyclic Graph

The following algorithm constructs a directed acyclic graph (DAG), where FREQ is the prefix dictionary described above.

def get_DAG(self, sentence):
    self.check_initialized()
    DAG = {}
    N = len(sentence)
    for k in range(N):
        tmplist = []
        i = k
        frag = sentence[k]
        while i < N and frag in self.FREQ:
            # If the frequency is greater than zero, append position i to the list keyed by k.
            if self.FREQ[frag]:
                tmplist.append(i)
            # A zero frequency means the prefix exists but is not itself a word; keep scanning.
            i += 1
            frag = sentence[k:i + 1]
        if not tmplist:
            tmplist.append(k)
        DAG[k] = tmplist
    return DAG

Finding the Maximum-Probability Path

def calc(self, sentence, DAG, route):
    N = len(sentence)
    route[N] = (0, 0)
    logtotal = log(self.total)
    for idx in range(N - 1, -1, -1):
        route[idx] = max((log(self.FREQ.get(sentence[idx:x + 1]) or 1) -
                          logtotal + route[x + 1][0], x) for x in DAG[idx])

def __cut_DAG_NO_HMM(self, sentence):
    re_eng = re.compile('[a-zA-Z0-9]', re.U)

    DAG = self.get_DAG(sentence)
    route = {}
    self.calc(sentence, DAG, route)
    x = 0
    N = len(sentence)
    buf = ''
    while x < N:
        y = route[x][1] + 1
        l_word = sentence[x:y]
        if re_eng.match(l_word) and len(l_word) == 1:
            buf += l_word
            x = y
        else:
            if buf:
                yield buf
                buf = ''
            yield l_word
            x = y
    if buf:
        yield buf
        buf = ''

Consider an interactive input field where the complete query must be segmented after every keystroke. Calling the segmentation API from scratch would repeat the entire computation for each new character. With an understanding of the maximum-probability-path algorithm, however, the result can be updated incrementally. This reduces computation at the cost of some additional memory, so whether it is worthwhile depends on the application.

As the algorithm shows, each newly typed character adds one node to the DAG. The dictionary determines which directed edges leave that node, after which dynamic programming updates the maximum-probability path.

HMM

Hidden Markov models are discussed in more detail in the separate article “Notes on HMMs.”

References


Share this post:

Previous Post
Word Segmentation Algorithms and a Simple Implementation, Part 1
Next Post
Algorithm Notes by Problem Type