Use the default seed from Random to reproduce historical results. - #522
Conversation
Correctly pass 64-bit seeds to Kiss64Random.
|
Thanks! I just realized there's a risk that with multithreaded build, it still won't be perfectly deterministic, since it might also depend on the timing of threads. But you said you're running it in single-threaded mode? |
|
Yep, that's right. A few months ago, @eddelbuettel and I were testing out the multi-threaded version of the index building for RcppAnnoy but realized that it gave different results to the single-threaded version, so we decided to stick to a single thread. FWIW I implement reproducible multi-threaded randomization by defining one seed for each task - in this case, building each tree. So the first tree gets a seed of Anyway, the CI failures seem to arise from the lack of C++11 in some of the builds. I don't know whether requiring C++11 would be an imposition in this day and age, but I suppose we could switch out the |
|
IIRC the multithreaded code added in 1.17 even requires C++14 so C++11 is not really a "constraint" there. We should just do the right thing for this advanced use, and also leave the sequential mode in "conservative" build settings for those CentOS 7 / RHEL 7 users with a compiler from the Cambrian age. |
|
After some struggle, I eventually remembered how to code against the C++03 standard. The latest version of this PR will allow the library to work on older compilers, though in such cases, a custom The per-tree seeds relate to a different problem as they will not reproduce historical results, just provide consistency across different parallelization schemes; so I will leave that for a different PR, if that is of interest. |
|
thanks for the PR. is this ready to be merged? |
|
Yep, fine with me. |
|
"What he said" -- I have no irons in this fire and trust Aaron on this. |
|
Is there some way to add a unit test for this btw? |
|
In theory - yes - we hard-code a search result into the test file (e.g., nearest neighbor indices for the first 20 observations in some test dataset, e.g., MNIST) and we compare all subsequent searches to that hard-coded result. Not the cleanest, but it will at least guarantee that we're getting back the same results as we did before. Probably a good idea to check that a different seed will give a different result, to make sure we don't accidentally make a self-defeating unit test that is resistant to stochasticity. I guess I'll try to put something together tonight. |
|
Well, that was a faceful of reality. I didn't notice that the Python module is compiled with multi-threading on, so any unit tests for consistency of the single-threaded policy won't apply there. In theory, the tests would be relevant for the Python 2 builds, but even then the results don't agree with what I get on my computer; I don't know why. Some combination of compiler settings, perhaps, causing changes in precision that are particularly impactful on the So I guess the actual answer to the question is: no, the unit tests to check this behavior are too fragile. (Makes me a little concerned about whether RcppAnnoy is giving the same result across platforms, though testing suggests that clang and g++ agree.) |
|
it's ok, let's merge this for now! thanks! |
The multi-threaded build is still not perfectly deterministic. I'm thinking of the same strategy as yours. Is their any chance that you could create a PR @LTLA, since you have already implemented it? |
|
Hm... jogging my memory, I don't think I ever actually implemented it for Annoy. I think I was speaking about my implementation for some other projects, and was just extrapolating that approach to Annoy. It shouldn't be too hard to it but you might as well start fresh because even if I had ever written it up, I suspect that particular branch is long gone. |
It seems that the latest Annoy library (in single-threaded mode) yields different results to the previous version, due to a change in the seed used for tree construction. The previous Annoy version initialized
Randomwith its default seed, while the current version effectively sets it to zero without manual intervention:annoy/src/annoylib.h
Lines 1144 to 1146 in ce7d767
(i.e., if I don't call
set_seed()somewhere along the line, and I use the single-threaded policy, then_seedends up being zero due to_is_seeded=falseandthread_idx=0.)This change is a bit - ahem - annoying for scientific applications where we would like to be able to reproduce the result of computational workflows. I mean, I get it, sometimes algorithms have to change and that different results are unavoidable; but in this case, the change does not seem necessary. One seed is as good as any other, and the old seed does look, at least, a bit better than just a flat zero.
This PR modifies the Annoy C++ library to recover the old results by ensuring that - for single-threaded applications - we are using the
Random's default seed. It modifies a few interfaces to ensure that the user can actually pass largeuints toRandomwithout truncation toint. Furthermore, we streamline the class a little by removing the need for theis_seededindicator.Some notes:
AnnoyIndexInterfacedefaults touint64_tunder the assumption that most people are usingKiss64Randomand thus any C++ code referring toAnnoyIndexInterfaces (e.g., in pointers to the base class) should continue to work.Randomnow have the extra requirement that adefault_seedvariable be present. This may affect some users who are passing in their own PRNGs.set_seed, which effectively truncates toint. RcppAnnoy has the same problem.annoy/src/annoymodule.cc
Lines 515 to 525 in ce7d767
Further context on this problem can be found in jlmelville/uwot#69.