On the proper treatment of idioms

This notebook will show a natural language system which parses and interprets sentences containing multiword expressions (or "idioms"), using modern functional programming techniques.

This is implemented in Haskell, and while the narrative should be clear without knowing the language, the code might not be. We're careful to define all the core machinery inside the notebook, rather than relying on outside imports. This is to make it extra clear what is going on algorithmically. We make an exception for a parser combinator library (megaparsec) and various standard imports like Text. However, in a real application, almost all of what we define by hand is already wrapped up more concisely in the free and recursion-schemes packages, so a non-tutorial version of this code would just rely on those.

First, imports:

Then, the core types, culminating in Tree, the type of binary branching trees.

Here is an example of a syntax tree

And another:

The goal of this notebook is to define a simple syntax and semantics for natural language, and an elegant extension to handle multiword expressions.

First, we'll need a type of denotations, or meanings.

Our choice of type for denotations, namely Meaning, is the disjoint sum of the types we would want to assign to NounPhrases, Sentences and so on. In general, we want to be able to freely vary this type, so most of our infrastructure will leave this type as a free parameter.

We now introduce the Lexicon type and an example lexicon:

This lexicon tells you how to assign a meaning to any node. If the node is just a leaf, i.e. a word, then it gets mapped to a meaning. If the node is a branching node, then we just say how to combine the meanings of the left and right branches.

This enables us to define our semantics with one very concise higher order function, which is parametrized by a lexicon, and returns a function from a syntax tree to a meaning.

This is all we need to interpret sentences! For example:

Some notes:

Because fold is so general, we can use it with other Lexicons, to obtain other kinds of results. For example, the linearization of bartSkateboards into a sentence is obtained as follows:

A more interesting example is the construction of a diagram from a sentence, like so:

Generalizing the semantics to multiword expressions

In natural language, we often encounter situations where standard compositionality doesn't feel sufficient. Consider:

Here, "Bart sees reason" is syntactically like "Bart sees Lisa", but there is a semantic difference. In particular, sees Lisa has a meaning which can be derived compositionally, by combining the meanings of sees and Lisa. By contrast, sees reason is more like a unit on its own, not derived by combining the meanings of sees and reason.

What we would like is to have sees reason as a unit in the lexicon. A hacky way to do this would be to concatenate to form "sees_reason" and treat it as a word. But it's easy to see that this doesn't address the broader problem. For once thing, the words in a multiword expression like "take Entity to task" need not be contiguous.

Multiword expressions can also have subparts which are derived compositionally, as in: takes NUMBER minutes, where NUMBER is itself the result of evaluating a subtree like three or two and a half.

A principled solution

What we want is to expand our notion of what a lexicon is, so that we can lookup not just words and rules of composition, but also evaluated trees. For example, we'd like to look up the tree "takes (NUMBER minutes)" in our lexicon, where NUMBER is the result of evaluating in turn the relevant subtree.

What's nice about our current approach is that instead of starting from scratch, we can just generalize our notions of a Lexicon and fold to accomodate our new needs. And in fact, the theoretical computer science literature provides exactly the correct generalization, in this rather technical paper. Fortunately, the paper's core insight is convertable into code, which we'll see below.

In short, we'll move from Lexicon to GeneralizedLexicon, which will be able to lookup trees. Correspondingly, instead of using fold for the semantics, we will use a generalization we'll call generalizedFold.

Lexicon took a Node containing either a word or a pair of Meanings and returned a Meaning. GeneralizedLexicon takes a Node containing either a word or a pair of evaluated trees and returns a Meaning.

An evaluated tree, with type Evaluated Node Meaning, is a tree where at each node, the result of evaluating the tree up to that node is stored alongside the node.

So, a generalized lexicon is able to make a meaning for a node depend not just on the meanings of the node's left and right subtrees, but on the entire evaluated left and right subtrees.

We now need to write generalizedFold, which should have type GeneralizedLexicon -> Tree -> Meaning. This is a little more complex than fold, but is still only around 10 lines:

Finally we can do:

The syntax

The second part of this story relates to the syntax. As it happens, there is a beautiful symmetry between the semantics and the syntax. Roughly, the semantics consumes trees, while the syntax produces them, and accordingly, instead of writing folds using Lexicons, we'll be writing unfolds using Grammars.

We now describe what a grammar is, and introduce a simple parser.

This is a Context Free grammar, which generates a set of sentences. To do this generating, we need an unfold function. unfold will take a starting Category and produce the set of all productions of the grammar that start with that category. We can represent this set as a MultiTree (defined below). This is a convenient representation, because it allows us to handle grammars which produce infinite languages, by appealing to Haskell's lazy evaluation.

We can view all the sentences of this MultiTree with another fold, as follows:

Here we generated all the sentences of exampleGrammar, and folded each into a string.

Parsing can be expressed in a similar way, but slightly more complex code is required. I'll also assume an understanding of combinator parsers. Feel free to skip this section if needed, and take the existence of the parser for granted.

The idea is that we will take the language, expressed as a MultiTree, and fold with a different "lexicon", this time folding into a combinator parser:

This parser works for infinite grammars! But only right recursion (rules like A -> B A). You need to be a bit cleverer to allow left recursion (e.g rules like A -> A B), so we omit that for now.

This parser also handles ambiguity! If there's more than one parse, you get all of them.

This parser is lazy! If there's a million parses, and you take the first 5, it stops the search for more once it gives you those 5. This makes it, in some settings, fast.

Feature agreement

It's straightforward to add feature agreement, to handle things in English like agreement in number between a noun and verb, but I've left this out for the sake of simplicity.

We now have a parser and a semantics. Putting this together, we can trivially write a function that takes strings and interprets them.

Some notes about what we have done so far:

Generalizing the syntax to multiword expressions

We saw already that there are multiword expressions that we want to handle specially in the semantics.

The same is true for the syntax, although the multiword expressions that we care about there might not be in complete overlap with the semantics. For example "sees reason" is a semantic multiword expression, but could in theory be treated normally in the syntax.

What are examples of syntactic multiword expressions? One example is "goes wild". You don't really want to treat "wild" as a noun phrase, or to have "go" take objects, otherwise you'd generate "Bart sees wild" or "Bart goes Bart".

As in the semantics, we also want multiword expressions with compositional parts, as in "all the [NOUN]". This needs to be a multiword expression, because "all" normally takes a noun, not a nounphrase like "the children", but at the same time, we want the word after "the" to be any noun that our grammar can generate.

These requirements on multiword expressions can be addressed by the exact counterpart of the generalizedFold, namely a generalizedUnfold and a generalizedGrammar:

Partial Node Category represents a syntax tree of which some subtrees simply terminate with a category.

While a standard grammar takes categories to either words or pairs of categories, a generalized grammar takes categories to either words or partial trees. This allows us to express our multiword expressions in the grammar, like so:

Finally, we'll need our generalized unfold:

In this last example, "all of the NOUN" is not in the lexicon, but is in the grammar. The parser therefore succeeds, but the evaluator fails.

Conclusion

There's a deep relationship between the theory of structured recursion and natural language. Idioms, or multiword expressions, can either be syntactic, in which case they are expressed in the GeneralizedGrammar or semantic, in which case they are expressed in the GeneralizedLexicon. Often they are both, but they don't need to be.

These two constructions are dual to each other in a formal sense (see appendix).

Appendix (Putting a technical hat on)

This story contains some mathematically quite rich ideas, which I don't highlight above, in the interest of clarity and simplicity. Below I outline the correspondences between well-known constructions, and the code in the notebooks.

Node is an example of a Functor.

Tree is the fix point of Node.

Our Lexicon type is known as an f-algebra in the literature, where f refers to the Functor in question. So we could call Lexicon a Node-algebra.

fold is formally a catamorphism, which is a map from the initial f-algebra.

Evaluated f, for a Functor f is the cofree comonad

generalizedFold is a generalized catamorphism, which is parametrized by a comonad and a distributive law. In particular, we choose the cofree comonad, which has a canonical distributive law.

Dually, our Grammar type is known as an f-coalgebra. So a Grammar is a Node-Coalebra.

unfold is an anamorphism which is a map into the terminal f-coalgebra.

Partial f, for a Functor f, is the free monad

generalizedUnfold is a generalized anamorphism, which is parametrized by a monad and a distributive law. In particular, we choose the free monad, which has a canonical distributive law.

In summary:

As for the approach to parsing proposed here, I don't know if it's known in the literature.