Repository navigation
Add LLMRecommender for LLM-based experimental design - #855
tobiasploetz wants to merge 89 commits into
Conversation
tobiasploetz
left a comment
There was a problem hiding this comment.
Converting discussion comments into review threads for easier inline discussion.
|
@Scienfitz @AdrianSosic @kalama-ai I will do the first round of reviews/feedback, feel free to ignore until we move it out of draft :) |
AVHopp
left a comment
There was a problem hiding this comment.
First round of comments. I hope that impementing those will solve the seralization issues as well as the most important design choice such that we can then see the CI in action. Note that I did not yet investigate the tests or the inner workings of the class in detail, but I think that doing small iterations like this is better than me providing too many comments :)
e419a9d to
f62c14b
Compare
8184fa5 to
84ed98c
Compare
There was a problem hiding this comment.
Pull request overview
This PR introduces an LLMRecommender to BayBE’s pure recommender stack, enabling LLM-driven experiment suggestions (via LiteLLM) for warm-starting campaigns before surrogate-based Bayesian optimization becomes effective.
Changes:
- Add
LLMRecommenderimplementation with prompt construction and robust JSON response parsing (including one-shot recovery). - Add optional dependency group
llm(Jinja2 + LiteLLM) plus optional-import plumbing and mypy config. - Add tests, documentation, and an end-to-end example (with pre-generated comparison plots) and adjust iteration tests to avoid auto-instantiating non-default-constructible recommenders.
Reviewed changes
Copilot reviewed 19 out of 22 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
baybe/recommenders/pure/llm/llm.py |
Implements LLMRecommender with LiteLLM call, recovery flow, and prompt/parse integration. |
baybe/recommenders/pure/llm/_prompts.py |
Builds Jinja2 prompts from search space + descriptions + optional measurements/objective. |
baybe/recommenders/pure/llm/_parsing.py |
Extracts/validates JSON suggestions into a DataFrame. |
baybe/recommenders/pure/llm/__init__.py |
Exposes LLMRecommender from the llm subpackage. |
baybe/recommenders/pure/__init__.py |
Re-exports LLMRecommender in pure recommenders. |
baybe/recommenders/__init__.py |
Re-exports LLMRecommender at the recommender package level. |
baybe/_optional/llm.py |
Adds optional imports for Jinja2 Template + LiteLLM completion. |
baybe/_optional/info.py |
Adds LLM_INSTALLED check based on Jinja2 + LiteLLM presence. |
baybe/exceptions.py |
Adds LLMResponseError and LLMResponseWarning. |
pyproject.toml |
Adds baybe[llm] extra with jinja2 + litellm. |
mypy.ini |
Adds mypy ignore section for litellm.*. |
tests/test_llm_recommender.py |
Adds mocked unit tests for prompting, parsing branches, recovery, and warnings. |
tests/serialization/test_llm_recommender_serialization.py |
Adds serialization roundtrip test via meta recommender wrapper. |
tests/conftest.py |
Adds helper to exclude non-default-constructible recommenders from subclass enumeration tests. |
tests/test_iterations.py |
Switches subclass enumeration to the new default-constructible helper. |
docs/components/recommenders.md |
Documents the new LLM recommender and the baybe[llm] extra. |
examples/LLM/llm_recommender.py |
Adds runnable warm-start example with mock fallback for CI/offline runs. |
examples/LLM/LLM_Header.md |
Adds header page for the LLM examples section. |
examples/LLM/llm_recommender_light.svg |
Adds pre-generated plot asset (light theme). |
examples/LLM/llm_recommender_dark.svg |
Adds pre-generated plot asset (dark theme). |
CHANGELOG.md |
Adds changelog entry for LLMRecommender. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Scienfitz
left a comment
There was a problem hiding this comment.
currently does not use all available metadata
b1c8cb4 to
5630f51
Compare
AdrianSosic
left a comment
There was a problem hiding this comment.
First round of comments, more coming
| OPTIMIZATION TARGETS: | ||
| {% for target in objective.targets %} | ||
| Target: {{ target.name }} | ||
| {% if target.metadata.description is not none %} |
There was a problem hiding this comment.
I'm not familiar with jinja logic, but are the conditions supposed to be python code? I.e. why none and not None? Perhaps you can enlighten me how to read this stuff 😅
There was a problem hiding this comment.
Let me summon @tobiasploetz here in case he knows an easy and quick win, I'll investigate.
There was a problem hiding this comment.
none seems to be the way that this is written in Jinja, even though it appears that None would also be fine. So I'd say we keep this.
There was a problem hiding this comment.
@AdrianSosic please resolve if you agree with this if not tell me what to do differently/verify.
| {% if pending_experiments is not none and not pending_experiments.empty %} | ||
| PENDING EXPERIMENTS: | ||
| The following experiments have already been proposed and are awaiting results. | ||
| Do not recommend these again. |
There was a problem hiding this comment.
I know, perhaps it doesn't even make a difference for the LLM, but perhaps we should at least state it correctly in our prompt. Instead of saying do not recommend again, how about something like or similar? Feel free to adjust or let me know if this is bullshit
| Do not recommend these again. | |
| Take these into account when generating your recommendations. In particular, only consider re-recommending parts of them if you have a strong reason to do so, for instance, when you are convinced that a second measurement is needed for noise reduction. |
There was a problem hiding this comment.
I think the prompt-tuning can only be done after some experimentation, and I like your block here. Makes more sense imo so I'll probably just add that
There was a problem hiding this comment.
In general, prompt tuning should be done with a proper evaluation set of tasks. The proposed modification looks very task-dependent.
There was a problem hiding this comment.
In this case, I'd say any sort of prompt-changing that actually changes content is deferred from now. Is that fine for you @AdrianSosic ?
There was a problem hiding this comment.
Let's use this thread to talk about the prompt and prompt tuning.
I realized that some prompt-tuning is actually necessary, as some of the models are really having issues with e.g. recommending the same point over and over, even if they are told not to do to. Once I have finished testing, we can start discussing the actual prompt here.
There was a problem hiding this comment.
The handling of the recovery as well as the "forbidden configurations" has changed quite a bit. Most of this has been done in 3b35a86, but it might make more sense/be easier to take a holistic look at how this is handled.
AdrianSosic
left a comment
There was a problem hiding this comment.
I'm sorry to be the showstopper here, but I think the PR requires a severe structural overhaul 😬 Much of the functionality is scattered / repeated (comments are pointing to several such places) and there are many smaller issues to fix as well. Haven't looked at the examples and tests yet, but we've already several dozen comments. Could it be that the PR was largely generated using LLMs – at least I can see the typical issues here...
If you want, I'm happy to have a general brainstorming session first, but let me know how you wish to proceed and how I can help best 👌🏼
|
|
||
|
|
||
| def build_prompt( | ||
| searchspace: SearchSpace, |
There was a problem hiding this comment.
RE signature: any particular reason why you chose this layout (i.e. ordering and positional vs kwonly)? I'm asking because the signature has a large overlap with other signatures we use in a recommendation context (e.g. RecommenderProtocol) and there we have established a sort of canonical ordering of the arguments. Would be nice if we could replicate that here
There was a problem hiding this comment.
Nope, and has been changed in 9d9adcf. Please let me know if the corresponding comment in the docstring is helping or should be removed.
@tobiasploetz: I'm talking to @AVHopp, in case this wasn't clear 🙃 |
|
@AdrianSosic thank you for the detailed review :) This is exactly what I expected. As far as I know, this PR was created using a lot of agentic assistance, both on my and @tobiasploetz end. So a lot of the things that you uncovered were to be expected, and I will take care of those. I think that there are some major points that we just need to align on to see how to shape this PR. The obvious one is everything around type safety, the other one is validation, which also touches upon the question of what exactly the PR should ship (e.g. do we want to restrict to discrete spaces for now). But I think it makes most sense to just discuss this in the corresponding threads. While these discussions are happening, I will start working on the more "mechanical" parts of the feedback - nomenclature, references and so on. |
1aa662f to
641592c
Compare
e36fce4 to
9a17935
Compare
| ) | ||
|
|
||
|
|
||
| def make_llm_alternating_recommender( |
There was a problem hiding this comment.
please lets introduce the two convenience recommenders in a format thats consistent with the other - a class
imo no need to make a factory here, did you consider jut deriving a special subclass from sequential and 2phase recommender?
There was a problem hiding this comment.
Implemented in eb4768a, please resolve if this structure is now as you imagined it.
| {{ objective.metadata.description }} | ||
|
|
||
| {% endif %} | ||
| OPTIMIZATION TARGETS: |
There was a problem hiding this comment.
please correct me if im wrong, but doesnt this objective section severly lack information still?
Example: The basic target informations like whether to maximize or minimize or transformations are not included. So the only info the llm can go off is objective.metadata. But the structure of single targets and how they are combined (which is objective and targets non-metadata info) should always be included too.
This leads for instance to the identical prompt for these two situations: NumericalTarget("y") and NumericalTarget("y", minimize=True). We should definitely not rely on the objective metadata correting this. For all we know obejctive metadat is allowed to be empty (just warned).
There was a problem hiding this comment.
Handling of obejctives has been improved in 0d90d46 and they are now handled in a similar way that we handle parameters.
Question maybe worth discussing: Do we also want to share all of the information? For example, we now also share the transformation, not sure if this is actually needed.
| error=initial_error, | ||
| original_response=content, | ||
| ) | ||
| recovery_content = self._query_model(recovery_prompt) |
There was a problem hiding this comment.
this sends a fresh query and dropped all the context of the original call (all important info like obejctive and contetx etc).
Is this intended? Is this not a problem because it is guaranteed that the LLM endpoint holds that context? How does sucha. query here work? OD we have to take care of carrying the context or not? Depending on that this might have a very bad influence here for the recovery call @tobiasploetz
There was a problem hiding this comment.
Recovery mechanism has been changed in general in 60afb19. It seems like every call is stateless (or at least one should assume so), hence this recovery mechanism now works by fully sending the original response together with what went wrong. This has the nice effect of us only needing a single template and function, so in my opinion this is quite an elegant solution if I may say so :) Resolve/comment if you agree/disagree.
| **self.litellm_args, | ||
| ) | ||
| except Exception as e: | ||
| raise LLMResponseError( |
There was a problem hiding this comment.
strictly speaking there was no response, wouldnt be calling it an auth error or similar be more precise and more useful for filtering?
There was a problem hiding this comment.
Has been changed in 77b4b2e where we improve general error handling, so please resolve here if this is to your liking.
Without this, `mypyc` would not correctly recognize the type of the recommenders list. Instead of an `ignore` comment, I added this typed converter instead.
…Constraint in tests
Testing revealed that models often repeat recommendations, even if they should not do so. Since we cannot directly hand the corresponding flags easily down to the recommender, we attempt to include forbidden configurations directly in the prompt.
… order Reorder `make_prompt` to (batch_size, searchspace, objective, measurements, pending_experiments), matching `RecommenderProtocol.recommend`, and make the LLM-specific experiment_description keyword-only.
This is more in-line with our usage patterns. We use our `to_tuple` converter as we cannot use a plain `tuple` due to python/mypy#8417.
Fixes two small issues: First, the prompt now only propagates `active_values` instead of all values. Second, it fixes a leak in `np` representation for numerical values.
There was a request to fully rewrite those, hence the current version was deleted.
Since `litellm` seems to be typed since this version this bump allows us to remove the package from previous exceptions regarding typing.
All LLM-related exceptions now carry an `LLM` prefix in their class names. The hierarchy was extended by differentiating between general call errors (with authentication errors as one special subcase), and response errors.
The `recommend` call now stashes necessary context for creating the prompts and delegates to `_recommend_hybrid`. As a consequence, validation and preprocessing of the `super` call is used and has been removed.
It has been decided that those should be added in a follow-up PR in order to decrease the scope of this one
Previously, some of the context and information was not used in the recovery prompt. To fix this, there is now a single general prompt that includes the full context, with an optional block containing recovery-related information if necessary.
We did not carry all of the relevant information for targets and objectives in the prompt. This has now been fixed by including views of both targets and objectives in the prompt, similar to the way we do it for parameters.
f06c49a to
0d90d46
Compare
It seems like newer versions of `mypy` require more detailed type information that the simple `type` annotation we had in `find_subclass`
Add LLMRecommender for LLM-based experimental design
Summary
Adds an LLMRecommender that queries a large language model (via LiteLLM) to propose experiments. It is primarily intended for warm-starting a campaign: before enough data exists to train a surrogate, the model can use parameter semantics and domain/literature priors to suggest more sensible points than random sampling. It slots into the recommender hierarchy like any other pure recommender and is typically combined with a Bayesian recommender via a TwoPhaseMetaRecommender.
Ported from #561 and substantially reworked for the current codebase.
What it does
Key design decisions
PureRecommender. It legitimately consumesmeasurements/pending_experimentsfor the prompt but is neither non-predictive nor Bayesian.recommend()is fully overridden and builds the result directly from the model response, so the recommend* hooks are intentionally not implemented.OPENAI_API_KEY,ANTHROPIC_API_KEY) selected by the model prefix — so there is deliberately no API-key attribute.related_dataor a feasibility check. These have been removed._prompts.py) and response parsing/validation (_parsing.py) live in dedicated submodules;llm.pyholds the recommender class.litellm/jinja2are lazy-imported so import baybe stays light.Testing
Docs & example