How to find list of unique affixes given a list of words?

Viewed 403

An affix can be a prefix (before word), infix (in the middle of a word), or suffix (after word). I have a list of 200k+ latin/greek names used in biological taxonomy. It turns out there is no centralized list of all the affixes used in the taxonomy, unfortunately, other than this very basic list.

The question is, how can I take that 200k+ list of latin/greek names, and divide it into a list of affixes (ideally using just plain JavaScript)?

I don't really know where to begin on this one. If I construct a trie, I need to somehow instead test for specific chunks of words. Or if the chunk can be extended, don't include the chunk until we reach a final extension of some sort...

const fs = require('fs')
const words = fs.readFileSync(`/Users/lancepollard/Downloads/all.csv`, 'utf-8').trim().split(/\n+/)
const trie = { children: {} }

words.forEach(word => addToTrie(trie, word))

function addToTrie(trie, word) {
  let letters = word.trim().split('')
  let node = trie
  let i = 0
  while (i < letters.length) {
    let letter = letters[i++]
    node = node.children[letter] = node.children[letter] || { children: {} }
  }
  node.isWord = true
}

It doesn't need to be exact, like each affix actually means something, it can be dirty (in that, some words mean something, some words don't). But it shouldn't just list every permutation of a word's letters sort of thing. It should include things which are "potential affix candidates", which are chunks which appear more than once in the list. This will at least get me partway there, and I can then manually go through and look up the definitions for each of these "chunks". Ideally, it should also tell whether it is a prefix/infix/suffix. Maybe the output is a CSV format affix,position.

You can get creative in how this is solved, as without knowing a list of possible affixes in advance, we don't know what the exact output should be. This is basically to try and find the affixes, as best as possible. If it includes things like aa- as a prefix, for example, which is probably a common sequence of letters yet I don't think is an affix, that is fine with me, it can be filtered out manually. But if there are two words (I am making this up), say abrogati and abrowendi, then abro would be a "common prefix", and that should be included in the final list, not abr, ab, and a, even though those are common too. Basically, the longest common prefix. However, if we have the words apistal and ariavi, we could say that a is a common prefix, so our final list would include a and abro.

To go into slightly more detail, say we have these two words aprineyanilantli and aboneyanomantli, they have the common prefix a-, and the common suffix -antli, as well as the infix -neyan-, so those should be in the final list.

It doesn't necessarily need to be efficient, as this is only going to run theoretically once, on the 200k+ list. But if it efficient as well, that would be bonus. Ideally though it shouldn't take hours to run, though I am not sure what's possible :)

Another example is this:

brevidentata
brevidentatum
brevidentatus
crassidentata
crassidentatum
crassidentatus

Here, the first 3 have a common prefix, brevidentat, then 2-3 have the common prefix brevidentatu. But later (with human knowledge), we find identat is probably the infix we desire, and a/um/us are word form suffixes. Also, we see that identat is an infix in the two words crass... and brev.... So the end result should be:

brav-
crass-
-identat-
-a
-us
-um

That, in theory, would be the ideal outcome. But you could also have this:

brav-
crass-
-identat-
-identata
-identatus
-identatum

That would also work, and we could do some simple filtering to filter those out later.

Note, I don't care about infixes in the sense of word parts that surround something else, like stufffoo...barstuff, where foo...bar wraps something. I just care about the word parts which are repeated, such as prefixes, suffixes, and stuff in the middle of words.

3 Answers

This is an interesting problem, and I have a sketch of a solution, with runnable code and somewhat reasonable -- but far from perfect -- output. It's easy, if not quick, to play with variants.

The idea is to first run through all the words, splitting them in every possible way, then to count the appearances of each prefix, infix, and suffix across all the words, and finally to use that information, together with a scoring function, to choose the best representation of each word.

The scoring functions I've tested involve combinations of the length of the prefix, the count of that prefix across all words, and the same factors for the suffix and affix. Generally I weigh the lengths much higher than counts, and I focus for now on the prefixes and only slightly weigh the suffixes.

Running this takes a handful of minutes, but more memory than Node gets by default. I run it as

node --max-old-space-size=8192 index

and that seems to be enough. I haven't tried it with 4GB.

My code looks like this, with the most recent (and so far my favorite) scoring function:

const {readFile, writeFile} = require ('fs') .promises
 
const range = (lo, hi) =>
  Array .from ({length: hi - lo}, (_, i) => i + lo)
 
const chooseTwo = (n) =>
  range (0, n) .flatMap (i => range (i + 1, n + 1) .map (j => [i, j]))
 
const maximumBy = (fn) => (xs) =>
  xs .reduce ((max, x) => {
    const xScore = fn (x);
    return xScore > max .score ? {score: xScore, val: x} : max;
  }, {score: -Infinity}) .val
 
const breakdown = (word) => {
  const len = word.length;
  const ranges = chooseTwo (len);
  return [
    ... ranges .map (([i, j]) => ({p: word .slice (0, i), i: word .slice (i, j), s: word .slice (j)})),
    ... range (0, len - 1) .map (i => ({p: '', i: word .slice (0, i), s: word .slice (i)})),
  ];
}

const score = (counts) => ({p, i, s}) =>
  Math .max (1, Math .sqrt (1 + counts .prefixes [p]) * p .length ** 2) *
  // Math .max (1, counts .infixes [i] * i .length ) *
  Math .max (1, counts .suffixes [s] * s .length)
 
const process = (words) => {
  const breakdowns = words .map (breakdown)
  const counts = breakdowns .reduce (
    (all, breakdown) => breakdown .reduce (
      (all, {p, i, s}) => {
        all .prefixes [p] = (all .prefixes [p] || 0) + 1;
        all .infixes [i] = (all .infixes [i] || 0) + 1;
        all .suffixes [s] = (all .suffixes [s] || 0) + 1;
        return all;
      },
      all
    ),
    {prefixes: {}, infixes: {}, suffixes: {}}
  )
 
  return breakdowns .map (maximumBy (score (counts)))
}
  
readFile ('./all.csv', 'utf8')
  .then (s => s.split ('\n'))
  .then (process)
  .then (breakdowns => breakdowns .map (({p, i, s}) => `${p ? `(${p}-)` : ''}(${i})${s ? `(-${s})` : ''}`))
  .then (words => writeFile ('./res.csv', words .join ('\n')), 'utf8')
  .then (() => console .log ('Result written'))

The first important function is breakdown, which, for instance, turns 'horse' into:

(h)(-orse)
(ho)(-rse)
(hor)(-se)
(hors)(-e)
(horse)
(h-)(o)(-rse)
(h-)(or)(-se)
(h-)(ors)(-e)
(h-)(orse)
(ho-)(r)(-se)
(ho-)(rs)(-e)
(ho-)(rse)
(hor-)(s)(-e)
(hor-)(se)
(hors-)(e)
()(-horse)
(h)(-orse)
(ho)(-rse)
(hor)(-se)
(h-)(orse)
(ho-)(rse)
(hor-)(se)
(hors-)(e)

which is stored internally with p, i, and s properties, for prefix, infix, and suffix, so it actually looks like this:

[
  {p: '', i: 'h', s: 'orse'},
  {p: '', i: 'ho', s: 'rse'},
  {p: '', i: 'hor', s: 'se'},
  {p: '', i: 'hors', s: 'e'},
  {p: '', i: 'horse', s: ''},
  {p: 'h', i: 'o', s: 'rse'},
  {p: 'h', i: 'or', s: 'se'},
  {p: 'h', i: 'ors', s: 'e'},
  {p: 'h', i: 'orse', s: ''},
  {p: 'ho', i: 'r', s: 'se'},
  {p: 'ho', i: 'rs', s: 'e'},
  {p: 'ho', i: 'rse', s: ''},
  {p: 'hor', i: 's', s: 'e'},
  {p: 'hor', i: 'se', s: ''},
  {p: 'hors', i: 'e', s: ''},
  {p: '', i: '', s: 'horse'},
  {p: '', i: 'h', s: 'orse'},
  {p: '', i: 'ho', s: 'rse'},
  {p: '', i: 'hor', s: 'se'},
  {p: 'h', i: 'orse', s: ''},
  {p: 'ho', i: 'rse', s: ''},
  {p: 'hor', i: 'se', s: ''},
  {p: 'hors', i: 'e', s: ''},
]

breakdown is built on two trivial functions: range creates an integer range, inclusive on the start, exclusive on the end, so that range (3, 12) yields [3, 4, 5, 6, 7, 8, 9, 10, 11]. And chooseTwo finds all pairs of distinct integers between 0 and n.

Our second main function is process, which does the algorithm described above using breakdown and maximumBy, which we use to choose the maximum valued breakdown using the score function. In between, we simply count the parts used.

This is all infrastructure. The important work is in score. You can alter this in so many ways. If it wasn't holiday time, I would love to play around with variants of this. But when you do, you should note that although it's easy to play with a small subset of the data and get reasonable-looking results, that doesn't always scale so reasonable to the complete data. So you will need to run the full code with various functions.

One thing I would suggest investigating is whether there is a reasonably accurate predictive hyphenating tool for English -- not dictionary-based, but either the result of reasonable first principles or of some machine learning runs. A good hyphenation decision might help you write a better score function.

If you want to see this in action in a small subset of your data, you can expand the following snippet:

const range = (lo, hi) =>
  Array .from ({length: hi - lo}, (_, i) => i + lo)
 
const chooseTwo = (n) =>
  range (0, n) .flatMap (i => range (i + 1, n + 1) .map (j => [i, j]))
 
const maximumBy = (fn) => (xs) =>
  xs .reduce ((max, x) => {
    const xScore = fn (x);
    return xScore > max .score ? {score: xScore, val: x} : max;
  }, {score: -Infinity}) .val

const breakdown = (word) => {
  const len = word.length;
  const ranges = chooseTwo (len);
  return [
    ... ranges .map (([i, j]) => ({p: word .slice (0, i), i: word .slice (i, j), s: word .slice (j)})),
    ... range (0, len - 1) .map (i => ({p: '', i: word .slice (0, i), s: word .slice (i)})),
  ];
}

const score = (counts) => ({p, i, s}) =>
  Math .max (1, Math .sqrt (1 + counts .prefixes [p]) * p .length ** 2) *
  // Math .max (1, counts .infixes [i] * i .length ) *
  Math .max (1, counts .suffixes [s] * s .length)
 
const process = (words) => {
  const breakdowns = words .map (breakdown)
  const counts = breakdowns .reduce (
    (all, breakdown) => breakdown .reduce (
      (all, {p, i, s}) => {
        all .prefixes [p] = (all .prefixes [p] || 0) + 1;
        all .infixes [i] = (all .infixes [i] || 0) + 1;
        all .suffixes [s] = (all .suffixes [s] || 0) + 1;
        return all;
      },
      all
    ),
    {prefixes: {}, infixes: {}, suffixes: {}}
  )
 
  return breakdowns .map (maximumBy (score (counts)))
}

const words = ["cristata", "cristatella", "cristatellidae", "cristatellus", "cristaticeps", "cristaticollis", "cristatiforme", "cristatifrons", "cristatigena", "cristatipes", "cristatispinosa", "cristatissimus", "cristatogobius", "cristatoides", "cristatolabra", "cristatopalpus", "cristatula", "cristatum", "cristatus", "cristavarius", "cristellaria", "cristeremaeus", "cristi", "cristianalemani", "cristiani", "cristibrachium", "cristicauda", "cristiceps", "cristicola", "cristicollis", "cristidigitus", "cristifer", "cristifera", "cristiferus", "cristiformis", "cristifrons", "cristigera", "cristiglans", "cristiloba", "cristimanus", "cristina", "cristinae", "cristipalpis", "cristipes", "cristirhizophorum", "cristis", "cristispira", "cristiverpa", "cristobal", "cristobala", "cristobalensis", "cristobalia", "cristoides", "cristonothrus", "cristophylla", "cristovalensis", "cristovaoi", "cristula", "cristulata", "cristulatum", "cristulatus", "cristuliflora", "cristulifrons", "cristulipes", "cristulum", "cristus", "crisulipora", "critchleyi", "critesion", "crithagra", "crithionina", "crithmifolia", "crithmoides", "critho", "crithodium", "crithopyrum", "critica", "criticum", "criticus", "critola", "critolaus", "critomolgus", "criton", "critonia", "crittersius", "crius", "crivellarii", "crnobog", "crnri", "croasdaleae", "croatanensis", "croatania", "croatanica", "croatica", "croaticum", "croaticus", "croatii", "crobylophorus", "crobylura", "crocaceae", "crocale", "crocallata", "crocallis", "crocana", "crocanthemum", "crocata", "crocatum", "crocatus", "crocea", "croceareolata", "crocearia", "croceata", "croceater", "croceator", "croceatus", "croceguttatus", "croceibacter", "croceicauda", "croceicincta", "croceicoccus", "croceicollis", "croceicornis", "croceiflorus", "croceipennis", "croceipes", "croceitalea", "croceitarsis", "croceithorax", "croceiventre", "croceiventris", "croceoida", "croceoides", "croceoinguinis", "croceola", "croceolanata", "croceomaculatus", "croceopodes", "croceosignatus", "croceovittata", "croceovittatus", "croces", "croceum", "croceus", "croci", "crociaeus", "crocias", "crocidema", "crocidium", "crocidolomiae", "crocidopoma", "crocidura", "crocidurae", "crocidurai", "crocidurinae", "crociduroides", "crocidurus", "crocifera", "crocigrapha", "crocina", "crocinae", "crocineus", "crocinitomix", "crocinopterus", "crocinosoma", "crocinubia", "crocinum", "crocinus", "crocisa", "crocisaeformis", "crockerella", "crockeri", "crockeria", "crockeriana", "crockerinus", "crockettorum", "crococephala", "crocodila", "crocodilensis", "crocodili", "crocodilia", "crocodilichthys", "crocodilinus", "crocodill", "crocodillicola", "crocodilorum", "crocodilosa", "crocodilurus", "crocodilus", "crocodyli", "crocodylia", "crocodylidae", "crocodylus", "crocogaster", "crocolita", "croconota", "croconotus", "crocopeplus", "crocopygia", "crocopygius", "crocorrhoa", "crocosema", "crocosmia", "crocosmiiflora", "crocostethus", "crocota", "crocothemis", "crocotia", "crocotila", "crocoturum", "crocotus", "crocro", "crocus", "crocusella", "crocuta", "crocutasis", "crocutella", "crocynia", "crocyniaceae", "croeciclava", "croeseri", "croesia", "croesioides", "croesus", "croftia", "croftiae", "croftii", "croftoni", "croftus", "crogmaniana", "croicensis", "croilia", "croisseti", "croix", "croizati", "croizatii", "crokeri", "cromagnonensis", "crombiei", "crombota", "cromeria", "cromerus", "cromileptes", "cromion", "cromis", "cromwellii", "cromyorhizon", "cronadun", "cronartiaceae", "cronartium", "cronebergi", "cronebergii", "croni"]
 
Promise .resolve (words)
  .then (process)
  .then (breakdowns => breakdowns .map (({p, i, s}) => `${p ? `(${p}-)` : ''}(${i})${s ? `(-${s})` : ''}`))
  .then (words => console .log (words .join ('\n')))
.as-console-wrapper {max-height: 100% !important; top: 0}

The format I use to display these is slightly different than suggested, as I wanted to allow for versions without prefixes or without suffixes but still be quite readable and unambiguous. Thus (crist-)(atellid)(-ae) should be quite clear. Each of the three sections is surrounded by parentheses. The prefix ends with a hyphen and the suffix begins with one. This is the format in the output file, but it would be trivial to change that -- just adjust the function supplied to breakdowns .map () in the last block.


A fascinating problem, and I hope I get some time next week to look at it more carefully.

Here is a simple approach, but it is probably in the hours period. Also, you could do it in JavaScript, but I'll take a generally Unixy approach that you could write in any language because that is simple to think about.

First, let's take your file, and add markers to the start/end of each word, and spaces between the letters. So your example would become:

^ b r e v i d e n t a t a $
^ b r e v i d e n t a t u m $
^ b r e v i d e n t a t u s $
^ c r a s s i d e n t a t a $
^ c r a s s i d e n t a t u m $
^ c r a s s i d e n t a t u s $

This is our general representation, space separated possible affixes. With the basic affixes being letters, begin, and end. Here we have, of course, found no affixes.


Here is what a single affix search pass looks like.

Take our file, and create tempfile of the distinct possible affix sections, followed by the line number of the word. (I say distinct so that if line 666 contains a b a b you don't get a b: 666 twice.) So our file starts off:

 ^ b: 1
 ^ b r: 1
 .
 .
 .
 ^ c r a s s i d e n t a t u s $: 6

Next we sort the file (just use the Unix LC_ALL=C sort tempfile > sortedtempfile command, the LC_ALL forces asciibetical sort). You now generate sortedtempfile which starts off:

 ^ b: 1
 ^ b: 2
 .
 .
 .
 ^ c r a s s i d e n t a t u s $: 6

Next run a custom command to give for each prefix that appears at least, say, 2 times, how many symbols you save using this as an affix, followed by the affix, followed by a list of lines where it appears. This generates a file tempsaved that starts off:

 3: ^ b: 1 2 3
 6: ^ b r e: 1 2 3
 .
 .
 .
 16: v i d e n t a t u: 2 3

Now do sorted -rn tempsaved > sortedtempsaved to sort from maximum savings to find the biggest savings first. This file now starts off

 36: ^ c r a s s i d e n t a t: 4 5 6
 33: ^ b r e v i d e n t a t: 1 2 3
 36: ^ c r a s s i d e n t a: 4 5 6

In the next function, we identify affixes until we encounter 2 on the same line number. Then go back to our original file and apply those. So in this pass we'd identify ^crassidentat and ^brevidentat. Then produce a new file which contains:

^brevidentat a $
^brevidentat u m $
^brevidentat u s $
^crassidentat a $
^crassidentat u m $
^crassidentat u s $

Now repeat.


In your example you'll wind up with the following set of affixes:

^crassidentat
^brevidentat
um$
us$
a$

If you added the words identata, identatum and identatus to the original list, the same algorithm would generate the following list of affixes instead

identat
^crass
^brev
um$
us$
a$

which is your stated ideal outcome.


My back of the envelope says that you should expect each pass to take several minutes. But we try to find lots of affixes per pass. So I wouldn't expect this to take more than a few dozen passes. Also the list will need human review afterwards. I don't think that there is much avoiding that.

Prefixes and Suffixes are easy with a Trie. However, a Trie won't help you with infixes.

Sample code for Trie (in Java, untested, incomplete)

class Node {
    private int cnt;
    private Map<Character, Node> children;

    Node() {
        cnt = 0;
        this.children = new HashMap<>();
    }

    Node(String s, int pos) {
        this();
        addChild(s, pos);
    }

    bool isLeaf() {
        return this.children.size() == 0
    }

    void addChild(String s, int pos) {
        if (pos == s.length()) {
            return;
        }

        char c = s.charAt(pos);
        if (children.containsKey(c)) {
            children.get(c).addChild(s, pos + 1);
        } else {
            children.put(c, new Node(s, pos + 1));
        }
        cnt++;
    }

    void removeChild(char c) {
        int ccnt = 0;
        Node child = children.remove(c);
        if (child != null) {
            ccnt = child.cnt;
        }
        cnt -= ccnt;
    }

    // other methods as necessary for traversal/value lookup...
}

class Solution {
    private Node preroot = new Node();
    private Node sufroot = new Node();

    void addWord(String s) {
        preroot.addChild(s, 0);
        sufroot.addChild(new StringBuilder(s).reverse().toString(), 0);
    }

    void findPrefixes(int minOccur) {
        // standard tree traversal on preroot,
        // starting at the left-most leaf.
        // when it finds a non-leaf with cnt >= minOccur
        // output all permutations and remove the child.
    }
}

Infixes

The problem with infixes is that you don't know where to start. i.e. take the strings abcdefgh and pppbcdefgzzzz, which have the common infix bcdefg. furthermore, how about abcdefgh and pppabcdefgzzz?

To solve this, you'll basically need to chop the words into all of its possible constituents, and point back to the word. Then iterate through the list of chops, sorted by descending length, and remove all entries associated with "used" words.

i.e. abc would become the lookup entries: abc, ab, bc, a, b, c. Then a lookup table would look like:

Word association to symbols:

{abc -> {abc, ab, bc, a, b, c}}

Map:

{abc -> { abc }}
{ab -> { abc }}
{bc -> { abc }}
{a -> { abc }}
{b -> { abc }}
{c -> { abc }}

when we add bcd, which adds the symbols: bcd, bc, cd, b, c, d, the word association is added and the lookup table gets updated:

{abc -> { abc }}
{bcd -> { bcd }}
{ab -> { abc }}
{bc -> { abc, bcd }}
{cd -> { bcd }}
{a -> { abc }}
{b -> { abc, bcd }}
{c -> { abc, bcd }}
{d -> { bcd }

Then use the length of the key for the map to dictate the sort order. Starting from the top, navigate until minimum occurrences is reached and then use the words in that list and remove the words from the construct. Removing the word from the map uses the word association saved earlier to look up the keys in the symbols map.

Related