Repository navigation
LLM example retrieval #412
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Enkidu93
wants to merge
6
commits into
main
Choose a base branch
from
llm-example-retrieval
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
6fcac7b
Add example retrieval code
Enkidu93 9125975
Update uv lock
Enkidu93 db62062
Address reviewer comments
Enkidu93 84ef3cf
Fix imports
Enkidu93 b3be109
Update mock retriever to use _examples
Enkidu93 2c99125
Address remaining claude comments
Enkidu93 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| from ...utils.packages import is_sklearn_available | ||
|
|
||
| if not is_sklearn_available(): | ||
| raise RuntimeError('sklearn is not installed. Install sil-machine with the "llm" extra.') | ||
|
|
||
| from .coverage_example_retriever import CoverageExampleRetriever | ||
| from .example import Example | ||
| from .example_retriever import ExampleRetriever | ||
| from .example_retriever_factory import ExampleRetrieverFactory | ||
| from .example_retriever_method import ExampleRetrievalMethod | ||
| from .tfidf_example_retriever import TfidfExampleRetriever | ||
| from .top_k_example_retriever import TopKExampleRetriever | ||
|
|
||
| __all__ = [ | ||
| "CoverageExampleRetriever", | ||
| "Example", | ||
| "ExampleRetrieverFactory", | ||
| "ExampleRetrievalMethod", | ||
| "ExampleRetriever", | ||
| "TfidfExampleRetriever", | ||
| "TopKExampleRetriever", | ||
| ] | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,106 @@ | ||
| from typing import List, Optional, cast | ||
|
|
||
| import numpy as np | ||
| from scipy.sparse import csr_matrix | ||
| from sklearn.feature_extraction.text import TfidfVectorizer | ||
| from sklearn.metrics.pairwise import linear_kernel | ||
|
|
||
| from .example import Example | ||
| from .example_retriever import ExampleRetriever | ||
|
|
||
|
|
||
| class CoverageExampleRetriever(ExampleRetriever): | ||
| _ANCHORS = 20 | ||
| _CANDIDATE_MULTIPLE = 4 | ||
| _MIN_CANDIDATES = 100 | ||
| _RELEVANCE_WEIGHT = 0.7 | ||
|
|
||
| def __init__(self, examples: List[Example]) -> None: | ||
| super().__init__(examples) | ||
| seen = set() | ||
| self._unique_example_indices = [] | ||
| for index, example in enumerate(self._examples): | ||
| if example not in seen: | ||
| self._unique_example_indices.append(index) | ||
| seen.add(example) | ||
|
|
||
| def _fit(self) -> None: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. FYI: F15. The |
||
| # TfidfVectorizer rejects a corpus with nothing to put in its vocabulary. | ||
| sources = [ex.source for ex in self._examples] | ||
| if not any(source.strip() for source in sources): | ||
| self._vectorizer = None | ||
| self._matrix = None | ||
| return | ||
| self._vectorizer = TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 5)) | ||
| self._matrix = cast(csr_matrix, self._vectorizer.fit_transform(sources)) | ||
|
|
||
| def _rank(self, query: str, k: int, exclude_indices: Optional[List[int]] = None) -> List[Example]: | ||
| if self._vectorizer is None or self._matrix is None: | ||
| return [] | ||
| query_vector = cast(csr_matrix, self._vectorizer.transform([query])) | ||
| similarities = linear_kernel(query_vector, self._matrix).ravel() | ||
| ranked = similarities.argsort()[::-1] | ||
| if exclude_indices is not None: | ||
| ranked = ranked[np.isin(ranked, exclude_indices, invert=True)] | ||
| ranked = ranked[np.isin(ranked, self._unique_example_indices)] | ||
| k = min(k, len(ranked)) | ||
| if k == 0: | ||
| return [] | ||
| if query_vector.nnz == 0: | ||
| return [self._examples[i] for i in ranked[:k].tolist()] | ||
| limit = max(self._CANDIDATE_MULTIPLE * k, self._MIN_CANDIDATES) | ||
| candidates = ranked[:limit] | ||
| if len(candidates) == 0: | ||
| return [] | ||
| chosen = self._choose_for_coverage(query_vector, self._matrix[candidates], similarities[candidates], k) | ||
| return [ | ||
| self._examples[example_index] | ||
| for example_index in sorted( | ||
| (int(candidates[i]) for i in chosen), key=lambda i: (similarities[i], -i), reverse=True | ||
| ) | ||
| ] | ||
|
|
||
| def _choose_for_coverage( | ||
| self, query_vector: csr_matrix, candidate_matrix: csr_matrix, similarities: np.ndarray, k: int | ||
| ) -> List[int]: | ||
| # Restrict the features that contribute to query coverage to those present in the query. | ||
| features = candidate_matrix[:, query_vector.indices].toarray() > 0 | ||
|
|
||
| # Normalize the query weights and the candidate similarities. | ||
| weights = query_vector.data / query_vector.data.sum() | ||
| relevance = similarities / max(float(similarities.max()), 1e-12) | ||
|
|
||
| # Always choose the first anchor_count most similar candidates. | ||
| anchor_count = min(self._ANCHORS, max(1, k // 2), len(features)) | ||
| chosen = list(range(anchor_count)) | ||
| available = np.ones(len(features), dtype=bool) | ||
| available[chosen] = False | ||
|
|
||
| # Keep track of how many times each feature is represented across the chosen examples. | ||
| counts = features[chosen].sum(axis=0).astype(float) | ||
|
|
||
| # Greedily select additional examples by repeatedly selecting | ||
| # the example with the highest composite relevance/coverage score | ||
| while len(chosen) < min(k, len(features)): | ||
| # The more the n-gram feature is important in the query, the higher the gain; | ||
| # the more times we've already selected an example that has that feature, the lower the gain. | ||
| relevance_weighted_coverage_gain = features @ (weights / (1.0 + counts)) | ||
|
|
||
| # Normalize the gains so they are comparable to the normalized relevance. | ||
| relevance_weighted_coverage_gain /= max(float(relevance_weighted_coverage_gain[available].max()), 1e-12) | ||
|
|
||
| # Create the composite utility score: a weighted sum of the gain and the normalized relevance score. | ||
| utility = ( | ||
| self._RELEVANCE_WEIGHT * relevance + (1 - self._RELEVANCE_WEIGHT) * relevance_weighted_coverage_gain | ||
| ) | ||
|
|
||
| # If the example has already been selected, set the score to -inf. | ||
| utility[~available] = -np.inf | ||
|
|
||
| # Greedily choose the highest-scoring candidate and update the available values and counts. | ||
| best = int(np.argmax(utility)) | ||
| chosen.append(best) | ||
| available[best] = False | ||
| counts += features[best] | ||
|
|
||
| return chosen | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| from dataclasses import dataclass | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class Example: | ||
| source: str | ||
| target: str |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| from abc import ABC, abstractmethod | ||
| from typing import List, Optional | ||
|
|
||
| from .example import Example | ||
|
|
||
|
|
||
| class ExampleRetriever(ABC): | ||
|
|
||
| def __init__(self, examples: List[Example]) -> None: | ||
| self._examples = list(examples) | ||
| self._fit() | ||
|
|
||
| @abstractmethod | ||
| def _fit(self) -> None: ... | ||
|
|
||
| def rank(self, query: str, k: int, exclude_example: Optional[Example] = None) -> List[Example]: | ||
| if k <= 0: | ||
| return [] | ||
| num_examples = len(self._examples) | ||
| exclude_indices = ( | ||
| [i for i, example in enumerate(self._examples) if example == exclude_example] | ||
| if exclude_example is not None | ||
| else [] | ||
| ) | ||
| if k >= (num_examples - len(exclude_indices)): | ||
| return [example for example in self._examples if example != exclude_example] | ||
|
claude[bot] marked this conversation as resolved.
Resolved
|
||
| return self._rank(query, k, exclude_indices) | ||
|
|
||
| @abstractmethod | ||
| def _rank(self, query: str, k: int, exclude_indices: Optional[List[int]] = None) -> List[Example]: ... | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| from typing import List | ||
|
|
||
| from .coverage_example_retriever import CoverageExampleRetriever | ||
| from .example import Example | ||
| from .example_retriever import ExampleRetriever | ||
| from .example_retriever_method import ExampleRetrievalMethod | ||
| from .tfidf_example_retriever import TfidfExampleRetriever | ||
|
|
||
|
|
||
| class ExampleRetrieverFactory: | ||
| @staticmethod | ||
| def create_example_retriever(method: ExampleRetrievalMethod, examples: List[Example]) -> ExampleRetriever: | ||
| match method: | ||
| case ExampleRetrievalMethod.TFIDF: | ||
| return TfidfExampleRetriever(examples) | ||
| case ExampleRetrievalMethod.COVERAGE: | ||
| return CoverageExampleRetriever(examples) | ||
| case _: | ||
| raise ValueError(f"Unable to create example retriever with method {method}") |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| from enum import Enum, auto | ||
|
|
||
|
|
||
| class ExampleRetrievalMethod(Enum): | ||
| TFIDF = auto() | ||
| COVERAGE = auto() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| from typing import List, cast | ||
|
|
||
| import numpy as np | ||
| from scipy.sparse import csr_matrix | ||
| from sklearn.feature_extraction.text import TfidfVectorizer | ||
|
|
||
| from ...tokenization.latin_word_tokenizer import LatinWordTokenizer | ||
| from .example import Example | ||
| from .top_k_example_retriever import TopKExampleRetriever | ||
|
|
||
|
|
||
| class TfidfExampleRetriever(TopKExampleRetriever): | ||
|
|
||
| def __init__(self, examples: List[Example]) -> None: | ||
| self._latin_tokenizer = LatinWordTokenizer() | ||
| super().__init__(examples) | ||
|
|
||
| def _fit(self) -> None: | ||
| # TfidfVectorizer rejects a corpus with nothing to put in its vocabulary. | ||
| sources = [ex.source for ex in self._examples] | ||
| if not any(self._tokenize(source) for source in sources): | ||
|
claude[bot] marked this conversation as resolved.
Resolved
|
||
| self._vectorizer = None | ||
| self._matrix = None | ||
| return | ||
| self._vectorizer = TfidfVectorizer( | ||
| lowercase=False, tokenizer=self._tokenize, token_pattern=None # pyright: ignore[reportArgumentType] | ||
| ) | ||
| self._matrix = cast(csr_matrix, self._vectorizer.fit_transform(sources)) | ||
|
|
||
| def _score_examples(self, query: str) -> np.ndarray: | ||
| if self._matrix is None or self._vectorizer is None: | ||
| return np.array([]) | ||
| vector = cast(csr_matrix, self._vectorizer.transform([query])) | ||
| return (self._matrix @ vector.T).toarray().ravel() | ||
|
|
||
| def _tokenize(self, text: str) -> List[str]: | ||
| # Strip all punctuation-only tokens | ||
| return [token for token in self._latin_tokenizer.tokenize(text.lower()) if any(c.isalnum() for c in token)] | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| from abc import ABC, abstractmethod | ||
| from typing import List, Optional | ||
|
|
||
| import numpy as np | ||
|
|
||
| from machine.translation.llm.example import Example | ||
|
|
||
| from .example_retriever import ExampleRetriever | ||
|
|
||
|
|
||
| class TopKExampleRetriever(ExampleRetriever, ABC): | ||
| @abstractmethod | ||
| def _score_examples(self, query: str) -> np.ndarray: ... | ||
|
|
||
| def _rank(self, query: str, k: int, exclude_indices: Optional[List[int]] = None) -> List[Example]: | ||
| scores = self._score_examples(query) | ||
| if len(scores) == 0: | ||
| return [ | ||
| example | ||
| for i, example in enumerate(self._examples) | ||
| if exclude_indices is None or i not in exclude_indices | ||
| ][:k] | ||
| n = scores.shape[0] | ||
| available = (n - len(exclude_indices)) if exclude_indices is not None else n | ||
| k = min(k, available) | ||
| if k <= 0: | ||
| return [] | ||
| if exclude_indices is not None: | ||
| scores = scores.copy() | ||
| for index in exclude_indices: | ||
| scores[index] = -np.inf | ||
| top = np.argpartition(-scores, k - 1)[:k] if k < n else np.arange(n) | ||
|
claude[bot] marked this conversation as resolved.
Resolved
|
||
| top_indices: List[int] = top[np.argsort(-scores[top])].tolist() | ||
| return [self._examples[i] for i in top_indices] | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| import numpy as np | ||
|
|
||
| from machine.translation.llm.top_k_example_retriever import TopKExampleRetriever | ||
|
|
||
|
|
||
| class MockExampleRetriever(TopKExampleRetriever): | ||
| def _fit(self) -> None: | ||
| pass | ||
|
|
||
| def _score_examples(self, query: str) -> np.ndarray: | ||
| return np.array([0 if query == e.source else (1 if query > e.source else -1) for e in self._examples]) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| from machine.translation.llm import Example | ||
| from machine.translation.llm.coverage_example_retriever import CoverageExampleRetriever | ||
|
|
||
|
|
||
| def test_coverage_retriever_prefers_an_uncovered_clause_to_a_near_duplicate(): | ||
| sources = [ | ||
| "the shepherd counted his sheep in the green field", | ||
| "the shepherd counted his sheep in the green field again", | ||
| "the shepherd counted all his sheep in the green field", | ||
| "fishermen mended nets", | ||
| "a woman drew water from the well", | ||
| ] | ||
| query = "the shepherd counted his sheep in the green field while fishermen mended nets" | ||
|
|
||
| retriever = CoverageExampleRetriever([Example(source, "") for source in sources]) | ||
|
|
||
| assert [example.source for example in retriever.rank(query, 2)] == [sources[0], sources[3]] | ||
|
|
||
|
|
||
| def test_coverage_retriever_keeps_the_closest_example(): | ||
| sources = ["the shepherd counted his sheep in the green field", "fishermen mended nets"] | ||
| query = "the shepherd counted his sheep in the green field while fishermen mended nets" | ||
|
|
||
| retriever = CoverageExampleRetriever([Example(source, "") for source in sources]) | ||
|
|
||
| assert [example.source for example in retriever.rank(query, 1)] == [sources[0]] | ||
|
|
||
|
|
||
| def test_coverage_retriever_selects_an_exact_duplicate_pair_only_once(): | ||
| examples = [Example("apple pie", "tarte"), Example("apple pie", "tarte"), Example("banana split", "banane")] | ||
| retriever = CoverageExampleRetriever(examples) | ||
|
|
||
| selected = retriever.rank("apple pie", 2) | ||
|
|
||
| assert examples[2] in selected | ||
| assert len([example for example in selected if example == examples[0]]) == 1 | ||
|
|
||
|
|
||
| def test_coverage_retriever_keeps_repeated_sources_with_different_targets(): | ||
| examples = [Example("apple pie", "tarte"), Example("apple pie", "tourte"), Example("banana split", "banane")] | ||
|
|
||
| assert set(CoverageExampleRetriever(examples).rank("apple pie", 2)) == set(examples[:2]) | ||
|
|
||
|
|
||
| def test_coverage_retriever_excludes_copies_of_the_excluded_example(): | ||
| examples = [Example("apple pie", "tarte"), Example("apple pie", "tarte"), Example("banana split", "banane")] | ||
|
|
||
| assert CoverageExampleRetriever(examples).rank("apple pie", 2, exclude_example=examples[0]) == [examples[2]] | ||
|
|
||
|
|
||
| def test_coverage_retriever_finds_nothing_when_all_remaining_examples_are_copies(): | ||
| examples = [Example("apple pie", "tarte"), Example("apple pie", "tarte")] | ||
|
|
||
| assert CoverageExampleRetriever(examples).rank("apple pie", 1, exclude_example=examples[0]) == [] | ||
|
|
||
|
|
||
| def test_coverage_retriever_matches_a_script_without_spaces(): | ||
| sources = ["他们在家里吃饭", "我们去学校", "明天下雨"] | ||
|
|
||
| retriever = CoverageExampleRetriever([Example(source, "") for source in sources]) | ||
|
|
||
| assert retriever.rank("我们明天去学校吗", 1)[0].source == sources[1] | ||
|
|
||
|
|
||
| def test_coverage_retriever_fills_k_when_the_query_has_no_known_ngrams(): | ||
| retriever = CoverageExampleRetriever( | ||
| [Example("apple pie", ""), Example("banana split", ""), Example("cherry tart", "")] | ||
| ) | ||
|
|
||
| assert len(set(retriever.rank("zzzz", 2))) == 2 | ||
|
|
||
|
|
||
| def test_coverage_retriever_handles_a_corpus_with_no_text(): | ||
| retriever = CoverageExampleRetriever([Example("", ""), Example(" ", "")]) | ||
|
|
||
| assert retriever.rank("anything", 1) == [] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| from testutils.mock_example_retriever import MockExampleRetriever | ||
|
claude[bot] marked this conversation as resolved.
Resolved
|
||
|
|
||
| from machine.translation.llm.example import Example | ||
|
|
||
|
|
||
| def test_example_retriever_k_greater_than_n(): | ||
| examples = [Example("a", "1"), Example("b", "2")] | ||
| retriever = MockExampleRetriever(examples) | ||
| retrieved_examples = retriever.rank("query", 3) | ||
| assert retrieved_examples == examples | ||
|
|
||
|
|
||
| def test_example_retriever_no_such_exclude(): | ||
| examples = [Example("a", "1"), Example("b", "2")] | ||
| retriever = MockExampleRetriever(examples) | ||
| retrieved_examples = retriever.rank("query", 2, Example("c", "3")) | ||
| assert retrieved_examples == examples | ||
|
|
||
|
|
||
| def test_example_retriever_exclude_example(): | ||
| examples = [Example("a", "1"), Example("b", "2")] | ||
| retriever = MockExampleRetriever(examples) | ||
| retrieved_examples = retriever.rank("query", 2, Example("a", "1")) | ||
| assert retrieved_examples == examples[1:] | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.