Add String.{edit_distance,spellcheck}, UTF-8 aware spellchecking functions - #13760
Conversation
|
I'm not sure if it works with row-by-row iteration and distance functions with transpositions, but edit distance can be computed in O(nd), where d is the result, by starting with a stripe of length 1 and widening it as the result at the diagonal increases. But maybe that's not necessary, as O(nd) can be achieved in a black box manner by starting with limit 1 and doubling it until the result no longer increases. |
|
Thanks for the very complete, very careful PR description! I haven't thought about it deeply yet, but here is a first round of feedback.
|
|
Also: I would not have |
|
Thanks for the comments @gasche.
I would say that for those tools that are programmed in OCaml your wish and your suggestion are in contradiction :–)
I don't think
In fact I thought that the code in Misc could simply have a fast past for the early bail out on ASCII identifiers: let edit_distance s0 s1 cutoff =
let is_ascii s = String.for_all Char.Ascii.is_valid s in
if not (is_ascii s0 && is_ascii s1)
then String.edit_distance ~limit:cutoff s0 s1 else
let len0 = String.length s0 and len1 = String.length s1 in
let limit = Int.min (Int.max len0 len1) cutoff in
if Int.abs (len1 - len0) > limit then limit else
String.edit_distance ~limit:cutoff s0 s1For now I opted not to do this in
Just to make things clear, I do this but the check is done after the strings have been decoded to
I rather have a simple interface. Sophisticated users can reuse the nice primitive |
|
So just to give an idea I made a toy benchmark which computes the distance Here are the numbers, the first run corresponds to the |
|
And just to round that up. I tried to compute the |
|
Thanks! Your benchmark computes edit distance three times. What is the performance of the 0-times version, that just does the rest of the logic? (This helps estimate relative differences in efficiency between the variants you measure.) The ascii version is quite nice; today all identifiers are ascii so this is just fine, but I'm thinking than in the future it is more likely that the user-provided identifier may have unicode, while the environment will be mostly ascii-only. In the use-case of computing the edit distance of one string against many, one "needle" in a haystack of other strings, (one could use a clever automata, anyways) we could in fact compute the length of that needle once, which would provide a significant speedup in the utf8-length-bailout version. But this makes the interface slightly uglier (providing an optional parameter for the uchar length of each argument?), so I'm not too convinced myself that this is a good idea. |
|
Here's the cost of just loading and iterating over the words of the dictionary: |
|
I'm satisfied with your benchmarking work: I think the ascii bailout is a reasonable approach, we could have it in Misc as your suggest or include it in Stdlib if you agree (this would have my mild preference, but no pressure). Looking at the Misc implementation again, I see that the let max_dist name =
match String.length name with
| 1 | 2 -> 0
| 3 | 4 -> 1
| 5 | 6 -> 2
| _ -> 3You propose |
Why not. But I'd like to also benchmark with an international list of words.
Yes. (I wanted to avoid to compute the Unicode length in the default but it's misguided, it's better to have this). |
0d3ebce to
003ee52
Compare
|
So I changed the default of Now regarding adding the optimization for ASCII by default I think I rather not. It's (rather obviously) slower as soon as there are characters beyond ASCII and the function advertises as working on Unicode, see numbers below. Besides Since I found a way to dump The following benchmark is done on the serbian dictionary which has the advantage of having both the cyrillic and latin script words of the language. This means we get a mix of words made of only ASCII characters, ASCII characters and multibyte characters and only multibyte character (686'453 words in total, the timings below are for ~2 million edit distance computations). Here are the results against a pure ASCII word ( Footnotes
|
003ee52 to
255c4a0
Compare
|
Just FTR I have overcome my indexing fears and have implemented in the benchmark a version which uses 3 buffers of FWIW it came out slower on the serbian dictionary benchmark; I suspect you pay for more branches and natural language words are rather small so the buffer sizes are not that different from those of the smaller word (however a function like It's also strictly more index fiddly and less readable. It could make a difference on long identifiers though but for now I decided to leave things as they are. It seems the performance tradeoffs are quite data dependent. |
|
I'm convinced by your performance measurements and feel okay with letting you decide whether to have the fast path in the stdlib version or not -- you are the expert. I didn't mean to throw you into an infinite iterative-deepening benchmarking loop. |
|
Oh don't worry that's self-imposed procrastination :–) I'd still like to run the bench on the dictionary of all qualified names of my opam switch, but that will have to wait a bit. |
|
(I thought that using this in utils/misc.ml would require a bootstrap, but in fact I was mistaken, I don't think we need a bootstrap to use the code in stdlib/foo.ml.) |
|
So I have a small script to dump all the qualified identifiers of an opam switch with Here again the results are quite data dependent, with short identifiers There are a few things that could be tried (e.g. rather than benchmark So here are a few results on the "opam dictionary", with > b0 -- bench-dict-opam --word=fald_left
Benchmark 1: /Users/dbuenzli/tmp/eddist/_b0/b/user/test-edit-distance/test-edit-distance --use-proposal --dict-file dict-opam --word=fald_left
Time (mean ± σ): 335.9 ms ± 1.3 ms [User: 320.2 ms, System: 13.9 ms]
Range (min … max): 334.0 ms … 337.7 ms 10 runs
Benchmark 2: /Users/dbuenzli/tmp/eddist/_b0/b/user/test-edit-distance/test-edit-distance --use-window --dict-file dict-opam --word=fald_left
Time (mean ± σ): 336.1 ms ± 1.5 ms [User: 320.2 ms, System: 13.3 ms]
Range (min … max): 334.3 ms … 338.8 ms 10 runs
Benchmark 3: /Users/dbuenzli/tmp/eddist/_b0/b/user/test-edit-distance/test-edit-distance --use-ascii --dict-file dict-opam --word=fald_left
Time (mean ± σ): 139.4 ms ± 0.4 ms [User: 129.6 ms, System: 8.3 ms]
Range (min … max): 138.9 ms … 140.6 ms 21 runs
Benchmark 4: /Users/dbuenzli/tmp/eddist/_b0/b/user/test-edit-distance/test-edit-distance --use-misc --dict-file dict-opam --word=fald_left
Time (mean ± σ): 70.5 ms ± 0.7 ms [User: 62.5 ms, System: 6.6 ms]
Range (min … max): 69.3 ms … 72.9 ms 41 runs
Benchmark 5: /Users/dbuenzli/tmp/eddist/_b0/b/user/test-edit-distance/test-edit-distance --just-iter --dict-file dict-opam --word=fald_left
Time (mean ± σ): 48.6 ms ± 0.8 ms [User: 40.9 ms, System: 6.2 ms]
Range (min … max): 47.4 ms … 50.9 ms 59 runs
Summary
/Users/dbuenzli/tmp/eddist/_b0/b/user/test-edit-distance/test-edit-distance --just-iter --dict-file dict-opam --word=fald_left ran
1.45 ± 0.03 times faster than /Users/dbuenzli/tmp/eddist/_b0/b/user/test-edit-distance/test-edit-distance --use-misc --dict-file dict-opam --word=fald_left
2.87 ± 0.04 times faster than /Users/dbuenzli/tmp/eddist/_b0/b/user/test-edit-distance/test-edit-distance --use-ascii --dict-file dict-opam --word=fald_left
6.91 ± 0.11 times faster than /Users/dbuenzli/tmp/eddist/_b0/b/user/test-edit-distance/test-edit-distance --use-proposal --dict-file dict-opam --word=fald_left
6.91 ± 0.11 times faster than /Users/dbuenzli/tmp/eddist/_b0/b/user/test-edit-distance/test-edit-distance --use-window --dict-file dict-opam --word=fald_left> b0 -- bench-dict-opam --word=List.fald_left
Benchmark 1: /Users/dbuenzli/tmp/eddist/_b0/b/user/test-edit-distance/test-edit-distance --use-proposal --dict-file dict-opam --word=List.fald_left
Time (mean ± σ): 372.3 ms ± 1.0 ms [User: 358.2 ms, System: 11.9 ms]
Range (min … max): 370.5 ms … 373.3 ms 10 runs
Benchmark 2: /Users/dbuenzli/tmp/eddist/_b0/b/user/test-edit-distance/test-edit-distance --use-window --dict-file dict-opam --word=List.fald_left
Time (mean ± σ): 368.6 ms ± 1.6 ms [User: 355.1 ms, System: 11.3 ms]
Range (min … max): 366.6 ms … 371.6 ms 10 runs
Benchmark 3: /Users/dbuenzli/tmp/eddist/_b0/b/user/test-edit-distance/test-edit-distance --use-ascii --dict-file dict-opam --word=List.fald_left
Time (mean ± σ): 172.8 ms ± 0.8 ms [User: 162.9 ms, System: 8.3 ms]
Range (min … max): 172.1 ms … 175.1 ms 16 runs
Benchmark 4: /Users/dbuenzli/tmp/eddist/_b0/b/user/test-edit-distance/test-edit-distance --use-misc --dict-file dict-opam --word=List.fald_left
Time (mean ± σ): 149.3 ms ± 0.5 ms [User: 140.3 ms, System: 7.7 ms]
Range (min … max): 148.4 ms … 150.3 ms 19 runs
Benchmark 5: /Users/dbuenzli/tmp/eddist/_b0/b/user/test-edit-distance/test-edit-distance --just-iter --dict-file dict-opam --word=List.fald_left
Time (mean ± σ): 48.3 ms ± 0.4 ms [User: 40.9 ms, System: 6.1 ms]
Range (min … max): 47.8 ms … 50.1 ms 59 runs
Summary
/Users/dbuenzli/tmp/eddist/_b0/b/user/test-edit-distance/test-edit-distance --just-iter --dict-file dict-opam --word=List.fald_left ran
3.09 ± 0.03 times faster than /Users/dbuenzli/tmp/eddist/_b0/b/user/test-edit-distance/test-edit-distance --use-misc --dict-file dict-opam --word=List.fald_left
3.58 ± 0.03 times faster than /Users/dbuenzli/tmp/eddist/_b0/b/user/test-edit-distance/test-edit-distance --use-ascii --dict-file dict-opam --word=List.fald_left
7.63 ± 0.07 times faster than /Users/dbuenzli/tmp/eddist/_b0/b/user/test-edit-distance/test-edit-distance --use-window --dict-file dict-opam --word=List.fald_left
7.71 ± 0.07 times faster than /Users/dbuenzli/tmp/eddist/_b0/b/user/test-edit-distance/test-edit-distance --use-proposal --dict-file dict-opam --word=List.fald_left> b0 -- bench-dict-opam --word=123456789012345678901234
Benchmark 1: /Users/dbuenzli/tmp/eddist/_b0/b/user/test-edit-distance/test-edit-distance --use-proposal --dict-file dict-opam --word=123456789012345678901234
Time (mean ± σ): 432.3 ms ± 11.3 ms [User: 417.1 ms, System: 13.0 ms]
Range (min … max): 408.8 ms … 446.6 ms 10 runs
Benchmark 2: /Users/dbuenzli/tmp/eddist/_b0/b/user/test-edit-distance/test-edit-distance --use-window --dict-file dict-opam --word=123456789012345678901234
Time (mean ± σ): 418.4 ms ± 8.4 ms [User: 403.5 ms, System: 12.5 ms]
Range (min … max): 402.4 ms … 429.4 ms 10 runs
Benchmark 3: /Users/dbuenzli/tmp/eddist/_b0/b/user/test-edit-distance/test-edit-distance --use-ascii --dict-file dict-opam --word=123456789012345678901234
Time (mean ± σ): 248.6 ms ± 2.6 ms [User: 237.8 ms, System: 9.2 ms]
Range (min … max): 244.6 ms … 251.1 ms 12 runs
Benchmark 4: /Users/dbuenzli/tmp/eddist/_b0/b/user/test-edit-distance/test-edit-distance --use-misc --dict-file dict-opam --word=123456789012345678901234
Time (mean ± σ): 367.3 ms ± 0.8 ms [User: 353.0 ms, System: 12.4 ms]
Range (min … max): 366.3 ms … 368.5 ms 10 runs
Benchmark 5: /Users/dbuenzli/tmp/eddist/_b0/b/user/test-edit-distance/test-edit-distance --just-iter --dict-file dict-opam --word=123456789012345678901234
Time (mean ± σ): 48.2 ms ± 0.5 ms [User: 40.9 ms, System: 6.0 ms]
Range (min … max): 47.4 ms … 50.2 ms 59 runs
Summary
/Users/dbuenzli/tmp/eddist/_b0/b/user/test-edit-distance/test-edit-distance --just-iter --dict-file dict-opam --word=123456789012345678901234 ran
5.16 ± 0.07 times faster than /Users/dbuenzli/tmp/eddist/_b0/b/user/test-edit-distance/test-edit-distance --use-ascii --dict-file dict-opam --word=123456789012345678901234
7.62 ± 0.08 times faster than /Users/dbuenzli/tmp/eddist/_b0/b/user/test-edit-distance/test-edit-distance --use-misc --dict-file dict-opam --word=123456789012345678901234
8.68 ± 0.19 times faster than /Users/dbuenzli/tmp/eddist/_b0/b/user/test-edit-distance/test-edit-distance --use-window --dict-file dict-opam --word=123456789012345678901234
8.97 ± 0.25 times faster than /Users/dbuenzli/tmp/eddist/_b0/b/user/test-edit-distance/test-edit-distance --use-proposal --dict-file dict-opam --word=123456789012345678901234 |
|
I agree than taking less than half a second on all names exported by all OCaml compilation units in opam is no cause for concern. In your List.fald_left test, your code is about 3x slower than Misc (if we substract the base iteration time), only 1.3x slower with the ascii fast path, and this is an acceptable code to pay in exchange for the warm fuzzy feeling of handling all of unicode correctly. |
gasche
left a comment
There was a problem hiding this comment.
Approved. (This is a stdlib change so it will need a second maintainer approval.)
I have not in fact reviewed the core implementation in details, but I'm broadly confident and there is a nice testsuite.
Minor comment: currently you reuse the Misc interface relatively to the limit parameter, that you cap the return value with limit. I briefly wondered if it wouldn't be nicer to return None in that case (when we know that the distance is at least limit, but do not know for sure that it is limit). But this makes the API less pleasant to use for people who don't provide a limit, so I guess it's not such a good idea. Oh well...
255c4a0 to
406c028
Compare
Having In the end I couldn't get out of my mind that |
0c05846 to
873f0c4
Compare
|
We have two maintainers approval, but I wonder if @Octachron wants to have a look before we merge. (As our resident second unicode-and-also-edit-distances nerd, or in case he has opinions on dependencies among stdlib modules.) @Octachron, could you in the next few days tell us whether you want to have a look (then we can of course wait longer) or are happy to let us merge right now? |
|
Yes, I am planning to have a look at the beginning of next week. |
This is mainly useful for String.spellcheck once it has reached a minimal distance of 0.
aa0a2c9 to
8671ebe
Compare
They are no longer needed since ocaml#13724 has been merged.
Note to reviewers, there's quite a bit of stdlib related PR's these days I don't mind if this doesn't get reviewed for 5.4, I did it now because I had to go over this again and the material is in my mind.
This PR adds two new functions to the String module for devising “did you mean?” spell-checking functions on UTF-8 text:
Almost any program which deals with user provided input that needs to be matched against known strings can have its usability improved on errors by a simple mindless call to
String.spellcheck, so it feels worth having them at hand in the Stdlib with good defaults.The OCaml compiler itself has a form of these functions in
Misc.edit_distanceandMisc.spellcheck. This PR makes sure that at least theMisc.edit_functionfunction can be replaced by the one proposed here. This in turn can fix the (unreported AFAIK) bug that these functions do not work meaningfully with the new modest support for Unicode letters inidentifiers.
String.edit_distanceLike
Misc.edit_distance, we use the optimal string alignement distance except we do it on arrays ofUchar.t. In contrast to the Levenshtein distance, OSA allows two letter transpositions to be counted as 1 edit rather than 2. In contrast to Damerau-Levenshtein, OSA does not allow the same subsequence to be modified twice. We do not compute Damerau-Levenshtein distance as this requires a counter per letter occuring in the string which is a bit more costly and unconvenient given that, we work onUchar.tletters (vs. say bytes), the data structures we have in the Stdlib, and the module dependency constraints.Like in
Misc.edit_distancewe add alimitparameter to limit the computation to a distance threshold. In this case only the values on a stripe of length2*limit+1around the diagonal of the dynamic programming matrix need to be computed. This significantly reduces the work done when you are interested in selecting strings that are not further away than a given limit which is usually the case in spellchecking tasks.Unlike
Misc.edit_distance, the proposed function has an unconditional allocation cost due to the internal conversion toUchar.tarrays on which the distance is effectively computed. To compensate this we reduce the dynamic programming matrix as is often suggested but not done inMisc.edit_distance(you only need a ring buffer of 3 rows) .Since the raw implementation result may be a bit difficult to understand, its implementation is introduced by a succession of three commits.
The first commit adds a classical OSA distance computation between two UTF-8 strings. It should be easy to match this implementation on pseudo code or the recurrence definition of the dynamic programming matrix that can be found e.g. on wikipedia. By looking at the indexation operations in the loop it is also easy to see that you don't need the full dynamic programming matrix but only a ring buffer of three rows.
The second commit makes the optimisation of only keeping a ring buffer of three rows of the smallest string. This makes the space requirements of the dynamic programming matrix
3*length(s)withsthe smallest string rather thanlength(s0)*length(s1).The third commit adds a
limitparameter to stop the computation once a maximal distance is reached. This involves computing the dynamic programming array only on a stripe of length2*limit+1around the diagonal and compute the minimal in a row for early exit (not present inMisc.edit_distance). This makes the algorithmO(limit*length(s))time.String.spellcheckThis is a ready made spell-checking function that can be used with small dictionaries represented by a list of string for correcting user input. Given a string
sto correct it simply returns the list of words in the dictionary whose edit distance is the smallest and at most at a user providedmax_dist svalue. Note that this favours corrections to shorter words (this can be disputed but the aim here is to have an easy function one can quickly reach for. More elaborate schemes, e.g. based on word probability, can be devised by using theString.edit_distanceprimitive).The
max_distfunctiondefaults to(this is no longer the case see discussion below). This default can be discussed, especially on small strings. The problem is that it likely depends on application usage. For exampleFun.const 2Misc.spellcheckadapts the distance according to identifiers length. I would have liked to find some pragmatic user study for a good default, but except for the internet rumor that users make generally no more than 2 of edit errors per word, nothing really came out. My aim with the current definition is that programmers can simply use the function without thinking and have a good enougth and easy to predict default behaviour. I mention in the docs that the default is subject to change, in case we do want to change it later (e.g. make it more likeMisc.spellcheck).Comments
Note that in contrast to
Misc.edit_distancewhich is able to bail out immediately on large string differences,String.edit_distancehas to create theUchar.tarrays for the strings before being able to bail out on that check. This has an impact on the gc behaviour ofMisc.spellcheck's performance ifenvis large.It would have been nice to define
edit_distancein the API ofArraybut we use it on subarrays here and we rather do not do an additional copy. This would have worked onDynarraybut I didn't want to introduce a dependency ofStringonDynarray(though given thatDynarrayare goodBuffer.tforUchar.tthat's not entirely absurd). I'm willing to do this change if people think there's interest (i.e. haveDynarray.edit_distance ?equal:('a -> 'a -> bool) -> ?limit:int -> 'a t -> 'a t -> int) but I rather do the factorization in a subsequent PR.This PR adds a dependencies to
Stringon theInt,ListandArraymodules (andUcharbut that was already a transitive dependency). Any of these dependencies can be removed (some at the cost of code repetition) Please tell me if there's the desire to trim any of these.