[ENH] kNN with the Cosine metric - #7309
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #7309 +/- ##
==========================================
- Coverage 88.98% 88.98% -0.01%
==========================================
Files 336 336
Lines 74544 74552 +8
==========================================
+ Hits 66332 66338 +6
- Misses 8212 8214 +2 🚀 New features to boost your workflow:
|
be40ad5 to
d3d90ca
Compare
|
@gmolledaj, thanks for a well-made PR. I have a few comments.
|
|
Thank you very much for the detailed review and for identifying the interaction with the learner's default preprocessors. I had not realized that adding the L2 normalizer to the user-defined preprocessor sequence would suppress the default preprocessing pipeline. I agree that this must be corrected. Overriding the learner's preprocess method seems like a better integration point, although I would prefer to wait for @markotoplak and @VesnaT to confirm the most appropriate hook before changing the implementation. Regarding categorical variables, my original use case consists of document embeddings, so the input is normally continuous. More generally, I agree that dummy indicator variables should probably not be affected by instance normalization. The exact ordering is important, however, because normalization must also coexist correctly with missing-value imputation. I would be happy to implement whichever ordering the maintainers consider consistent with Orange's preprocessing architecture. Concerning Manhattan distance, my original proposal was specifically motivated by the exact neighbour-ranking equivalence between cosine distance and Euclidean distance on L2-normalized vectors. L1 normalization combined with Manhattan distance does not provide the same cosine-equivalence argument, so I would prefer not to add it merely for symmetry unless there is a separate use case for it. Presenting this as separate options such as Euclidean and Euclidean (normalized) also sounds reasonable and would simplify the GUI logic. Before following that pattern, I would only like to confirm that “normalized” in the Distances widget refers to the same per-instance normalization rather than feature-wise normalization. If it does, consistency with that widget would be a strong argument in favour of this design. Thank you also for offering to write the settings migration. Once there is agreement on the preprocessing hook and GUI design, I will be glad to revise the PR accordingly. |
|
I'm sorry, my bad. I have read that your intention was to implement cosine distance, but I forgot it and reverted back to thinking about feature-wise normalization. Thanks for your patience. :) This shows that @markotoplak is right: if this is a cosine distance, why not call it such? Computing Euclidean distance on normalized rows (rather than passing I need to revisit my points.
|
|
Thank you for the clarification, Janez. I completely agree that, from a user's perspective, someone working with text embeddings will naturally look for "cosine" rather than "L2 normalization". If the consensus is that the GUI should expose it as "Cosine", I have no objection to that. I only wanted to clarify one technical point. My implementation does not introduce a cosine metric into the kNN learner. Instead, it adds an optional row-wise L2 normalization step before applying the existing Euclidean metric. The motivation comes from the mathematical equivalence that, for unit-normalized embeddings, Euclidean kNN yields the same neighbour ranking as cosine similarity. In that sense, I see it as a cosine-equivalent approach rather than a cosine implementation. Regarding the preprocessing pipeline, thank you for pointing out the issue. I now have a revised implementation that no longer injects the L2 normalizer into the user preprocessors. Instead, it preserves both the learner's default preprocessing pipeline and any external preprocessors connected to the widget, and applies the optional L2 normalization afterwards before fitting the Euclidean kNN. I'll update the PR with this approach. I also agree that the handling of categorical variables should be discussed separately. My original use case is document embeddings, where all features are continuous, but I'm happy to follow whichever behaviour is considered most appropriate for Orange. |
d3d90ca to
e448af7
Compare
|
Updated the PR following the review comments. The L2 normalization is no longer added to the user-defined preprocessor list. The classification and regression learners now override preprocess(), first preserving Orange’s standard preprocessing pipeline and any externally supplied preprocessors, and then applying row-wise L2 normalization. I also added tests covering: · dense and sparse data, I rebased the branch onto the current master and ran: pytest Orange/widgets/model/tests/test_owknn.py Result: 29 passed, 3 skipped. The UI is still exposed as the Euclidean metric with an optional “Normalize instances (L2; cosine-equivalent)” checkbox. I left the possible change to a dedicated “Cosine” entry in the metric combo for maintainers to decide. |
|
Thanks! I pulled the latest changes, tested the new implementation locally (including on an embeddings workflow), and everything looks good to me. |
|
@gmolledaj , reading your code and thinking about removing the checkbox, I stumbled upon an alternative solution, which I pushed as another commit to this PR; we can remove it if we find it's not what we want. The best way to review it is to compare it with master, not as a separate commit, because it a lot of code from your previous commits. The idea is to not use the preprocessor mechanism at all, but just normalize within a fit method and pass "euclidean" as an argument to scikit. With this, the changes in the widget are minimal (and require no tests), and other changes are much simpler to implement (and test), too. Please see if this works as you intended. @markotoplak or @VesnaT would also need to check whether I have correctly overridden the wrapper. @VesnaT, I have put the code that manipulated the learner's params into |
|
@gmolledaj , thanks, I was writing the previous message before seeing you already reviewed the changes. Now we must wait for @markotoplak or @VesnaT to review the PR. |
|
To me this PR seems fine. The existing code, where |

Issue
Implements the proposed solution discussed in #7270.
Description of changes
This PR adds an optional "Normalize instances (L2)" setting to the kNN widget.
When enabled, each input instance is normalized to unit L2 norm before learning by adding an internal preprocessor to the learner pipeline while preserving any existing preprocessors.When enabled, the learner first runs Orange’s regular preprocessing pipeline and then applies row-wise L2 normalization before fitting Euclidean kNN.
The option is available only when the Euclidean metric is selected, since L2-normalized Euclidean distance provides the same nearest-neighbor ranking as cosine similarity for embeddings. This makes the widget more suitable for text embedding workflows without requiring a Python Script widget or globally modifying the preprocessing pipeline.
Includes