Coverage for src/local_deep_research/benchmarks/optimization/optuna_optimizer.py: 79%
368 statements
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-20 01:24 +0000
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-20 01:24 +0000
1"""
2Optuna-based parameter optimizer for Local Deep Research.
4This module provides the core optimization functionality using Optuna
5to find optimal parameters for the research system, balancing quality
6and performance metrics.
7"""
9import os
10from pathlib import Path
11import time
12from datetime import datetime, UTC
13from functools import partial
14from typing import Any, Callable, Dict, List, Optional, Tuple
16import joblib
17import numpy as np
18import optuna
20# (matplotlib / optuna.visualization are imported lazily — see
21# _ensure_plotting_loaded below and #4431.)
23from local_deep_research.benchmarks.efficiency.speed_profiler import (
24 SpeedProfiler,
25)
26from local_deep_research.security import sanitize_data
27from loguru import logger
29from local_deep_research.benchmarks.evaluators import (
30 CompositeBenchmarkEvaluator,
31)
33# Import benchmark evaluator components
35# Visualization libraries (matplotlib + optuna.visualization) are imported
36# LAZILY — see _ensure_plotting_loaded below and #4431. find_spec only
37# probes availability; it does NOT execute/import the module, so importing
38# this file (and therefore `local_deep_research.benchmarks`) never pays
39# matplotlib's ~60s cold-import cost.
40import importlib.util
42PLOTTING_AVAILABLE = importlib.util.find_spec("matplotlib") is not None
43if not PLOTTING_AVAILABLE: 43 ↛ 44line 43 didn't jump to line 44 because the condition on line 43 was never true
44 logger.warning("Matplotlib not available, visualization will be limited")
47# Module-level placeholders so tests can @patch these names and so the
48# loader below can fill them in place. None until first real visualization.
49plt = None
50Line2D = None
51plot_contour = None
52plot_optimization_history = None
53plot_param_importances = None
54plot_slice = None
57def _ensure_plotting_loaded():
58 """Import matplotlib + optuna.visualization into module globals on first
59 use.
61 Deferred from module load on purpose: matplotlib's import is heavy and,
62 under the 2-core CI runner's GIL/CPU starvation, stretched to ~60s while
63 holding the import lock. Because benchmarks/__init__.py imports this
64 module, a module-level matplotlib import froze the whole werkzeug request
65 pipeline the first time any request touched `local_deep_research.benchmarks`
66 — the flaky UI-shard navigation timeouts (#4431). The
67 _create_*_visualizations methods call this after their PLOTTING_AVAILABLE
68 guard; visualization only happens in benchmark-optimization runs, never
69 on a request path. Early-returns if already loaded (or a test has patched
70 plt) so it never clobbers mocks.
71 """
72 global plt, Line2D
73 global plot_contour, plot_optimization_history
74 global plot_param_importances, plot_slice
75 if plt is not None:
76 return
77 import matplotlib.pyplot as plt
78 from matplotlib.lines import Line2D
79 from optuna.visualization import (
80 plot_contour,
81 plot_optimization_history,
82 plot_param_importances,
83 plot_slice,
84 )
87class OptunaOptimizer:
88 """
89 Optimize parameters for Local Deep Research using Optuna.
91 This class provides functionality to:
92 1. Define search spaces for parameter optimization
93 2. Evaluate parameter combinations using objective functions
94 3. Find optimal parameters via Optuna
95 4. Visualize and analyze optimization results
96 """
98 def __init__(
99 self,
100 base_query: str,
101 output_dir: str = "optimization_results",
102 model_name: Optional[str] = None,
103 provider: Optional[str] = None,
104 search_tool: Optional[str] = None,
105 temperature: float = 0.7,
106 n_trials: int = 30,
107 timeout: Optional[int] = None,
108 n_jobs: int = 1,
109 study_name: Optional[str] = None,
110 optimization_metrics: Optional[List[str]] = None,
111 metric_weights: Optional[Dict[str, float]] = None,
112 progress_callback: Optional[Callable[[int, int, Dict], None]] = None,
113 benchmark_weights: Optional[Dict[str, float]] = None,
114 ):
115 """
116 Initialize the optimizer.
118 Args:
119 base_query: The research query to use for all experiments
120 output_dir: Directory to save optimization results
121 model_name: Name of the LLM model to use
122 provider: LLM provider
123 search_tool: Search engine to use
124 temperature: LLM temperature
125 n_trials: Number of parameter combinations to try
126 timeout: Maximum seconds to run optimization (None for no limit)
127 n_jobs: Number of parallel jobs for optimization
128 study_name: Name of the Optuna study
129 optimization_metrics: List of metrics to optimize (default: ["quality", "speed"])
130 metric_weights: Dictionary of weights for each metric (e.g., {"quality": 0.6, "speed": 0.4})
131 progress_callback: Optional callback for progress updates
132 benchmark_weights: Dictionary mapping benchmark types to weights
133 (e.g., {"simpleqa": 0.6, "browsecomp": 0.4})
134 If None, only SimpleQA is used with weight 1.0
135 """
136 self.base_query = base_query
137 self.output_dir = output_dir
138 self.model_name = model_name
139 self.provider = provider
140 self.search_tool = search_tool
141 self.temperature = temperature
142 self.n_trials = n_trials
143 self.timeout = timeout
144 self.n_jobs = n_jobs
145 self.optimization_metrics = optimization_metrics or ["quality", "speed"]
146 self.metric_weights = metric_weights or {"quality": 0.6, "speed": 0.4}
147 self.progress_callback = progress_callback
149 # Initialize benchmark evaluator with weights
150 self.benchmark_weights = benchmark_weights or {"simpleqa": 1.0}
151 self.benchmark_evaluator = CompositeBenchmarkEvaluator(
152 self.benchmark_weights
153 )
155 # Normalize weights to sum to 1.0
156 total_weight = sum(self.metric_weights.values())
157 if total_weight > 0:
158 self.metric_weights = {
159 k: v / total_weight for k, v in self.metric_weights.items()
160 }
162 # Generate a unique study name if not provided
163 timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S")
164 self.study_name = study_name or f"ldr_opt_{timestamp}"
166 # Create output directory
167 os.makedirs(output_dir, exist_ok=True)
169 # Store the trial history for analysis
170 self.trials_history: List[Dict[str, Any]] = []
172 # Storage for the best parameters and study
173 self.best_params: Optional[Dict[str, Any]] = None
174 self.study: Optional[optuna.Study] = None
176 def optimize(
177 self, param_space: Optional[Dict[str, Any]] = None
178 ) -> Tuple[Dict[str, Any], float]:
179 """
180 Run the optimization process using Optuna.
182 Args:
183 param_space: Dictionary defining parameter search spaces
184 (if None, use default spaces)
186 Returns:
187 Tuple containing (best_parameters, best_score)
188 """
189 param_space = param_space or self._get_default_param_space()
191 # Create a study object
192 storage_name = f"sqlite:///{self.output_dir}/{self.study_name}.db"
193 self.study = optuna.create_study(
194 study_name=self.study_name,
195 storage=storage_name,
196 load_if_exists=True,
197 direction="maximize",
198 sampler=optuna.samplers.TPESampler(seed=42),
199 )
201 # Create partial function with param_space
202 objective = partial(self._objective, param_space=param_space)
204 # Log optimization start
205 logger.info(
206 f"Starting optimization with {self.n_trials} trials, {self.n_jobs} parallel jobs"
207 )
208 logger.info(f"Parameter space: {param_space}")
209 logger.info(f"Metric weights: {self.metric_weights}")
210 logger.info(f"Benchmark weights: {self.benchmark_weights}")
212 # Initialize progress tracking
213 if self.progress_callback:
214 self.progress_callback(
215 0,
216 self.n_trials,
217 {
218 "status": "starting",
219 "stage": "initialization",
220 "trials_completed": 0,
221 "total_trials": self.n_trials,
222 },
223 )
225 try:
226 # Run optimization
227 self.study.optimize(
228 objective,
229 n_trials=self.n_trials,
230 timeout=self.timeout,
231 n_jobs=self.n_jobs,
232 callbacks=[self._optimization_callback],
233 show_progress_bar=True,
234 )
236 # Store best parameters
237 if self.study is None: 237 ↛ 238line 237 didn't jump to line 238 because the condition on line 237 was never true
238 raise RuntimeError("Study was not created")
239 _completed_study = self.study
240 self.best_params = _completed_study.best_params
242 # Save the results
243 self._save_results()
245 # Create visualizations
246 self._create_visualizations()
248 if self.best_params is None: 248 ↛ 249line 248 didn't jump to line 249 because the condition on line 248 was never true
249 raise RuntimeError("No best parameters found")
250 logger.info(
251 f"Optimization complete. Best parameters: {self.best_params}"
252 )
253 logger.info(f"Best value: {_completed_study.best_value}")
255 # Report completion
256 if self.progress_callback:
257 self.progress_callback(
258 self.n_trials,
259 self.n_trials,
260 {
261 "status": "completed",
262 "stage": "finished",
263 "trials_completed": len(_completed_study.trials),
264 "total_trials": self.n_trials,
265 "best_params": self.best_params,
266 "best_value": _completed_study.best_value,
267 },
268 )
270 return self.best_params, _completed_study.best_value
272 except KeyboardInterrupt:
273 logger.info("Optimization interrupted by user")
274 # Still save what we have
275 self._save_results()
276 self._create_visualizations()
278 if self.study is None: 278 ↛ 279line 278 didn't jump to line 279 because the condition on line 278 was never true
279 raise RuntimeError("Study was not created")
280 _interrupted_study = self.study
281 # Report interruption
282 if self.progress_callback:
283 self.progress_callback(
284 len(_interrupted_study.trials),
285 self.n_trials,
286 {
287 "status": "interrupted",
288 "stage": "interrupted",
289 "trials_completed": len(_interrupted_study.trials),
290 "total_trials": self.n_trials,
291 "best_params": _interrupted_study.best_params,
292 "best_value": _interrupted_study.best_value,
293 },
294 )
296 return _interrupted_study.best_params, _interrupted_study.best_value
298 def _get_default_param_space(self) -> Dict[str, Any]:
299 """
300 Get default parameter search space.
302 Returns:
303 Dictionary defining the default parameter search spaces
304 """
305 return {
306 "iterations": {
307 "type": "int",
308 "low": 1,
309 "high": 5,
310 "step": 1,
311 },
312 "questions_per_iteration": {
313 "type": "int",
314 "low": 1,
315 "high": 5,
316 "step": 1,
317 },
318 "search_strategy": {
319 "type": "categorical",
320 "choices": [
321 "source-based",
322 "focused-iteration",
323 "focused-iteration-standard",
324 "topic-organization",
325 ],
326 },
327 "max_results": {
328 "type": "int",
329 "low": 10,
330 "high": 100,
331 "step": 10,
332 },
333 }
335 def _objective(
336 self, trial: optuna.Trial, param_space: Dict[str, Any]
337 ) -> float:
338 """
339 Objective function for Optuna optimization.
341 Args:
342 trial: Optuna trial object
343 param_space: Dictionary defining parameter search spaces
345 Returns:
346 Score to maximize
347 """
348 # Generate parameters for this trial
349 params: Dict[str, Any] = {}
350 for param_name, param_config in param_space.items():
351 param_type = param_config["type"]
353 if param_type == "int":
354 params[param_name] = trial.suggest_int(
355 param_name,
356 param_config["low"],
357 param_config["high"],
358 step=param_config.get("step", 1),
359 )
360 elif param_type == "float":
361 params[param_name] = trial.suggest_float(
362 param_name,
363 param_config["low"],
364 param_config["high"],
365 step=param_config.get("step"),
366 log=param_config.get("log", False),
367 )
368 elif param_type == "categorical":
369 params[param_name] = trial.suggest_categorical(
370 param_name, param_config["choices"]
371 )
373 # Log the trial parameters
374 logger.info(f"Trial {trial.number}: {params}")
376 # Update progress callback if available
377 if self.progress_callback:
378 self.progress_callback(
379 trial.number,
380 self.n_trials,
381 {
382 "status": "running",
383 "stage": "trial_started",
384 "trial_number": trial.number,
385 "params": params,
386 "trials_completed": trial.number,
387 "total_trials": self.n_trials,
388 },
389 )
391 # Run an experiment with these parameters
392 try:
393 start_time = time.time()
394 result = self._run_experiment(params)
395 duration = time.time() - start_time
397 # Store details about the trial
398 trial_info = {
399 "trial_number": trial.number,
400 "params": params,
401 "result": result,
402 "score": result.get("score", 0),
403 "duration": duration,
404 "timestamp": datetime.now(UTC).isoformat(),
405 }
406 self.trials_history.append(trial_info)
408 # Update callback with results
409 if self.progress_callback:
410 self.progress_callback(
411 trial.number,
412 self.n_trials,
413 {
414 "status": "completed",
415 "stage": "trial_completed",
416 "trial_number": trial.number,
417 "params": params,
418 "score": result.get("score", 0),
419 "trials_completed": trial.number + 1,
420 "total_trials": self.n_trials,
421 },
422 )
424 logger.info(
425 f"Trial {trial.number} completed: {params}, score: {result['score']:.4f}"
426 )
428 return float(result["score"])
429 except Exception as e:
430 logger.exception(f"Error in trial {trial.number}")
432 # Update callback with error
433 if self.progress_callback:
434 self.progress_callback(
435 trial.number,
436 self.n_trials,
437 {
438 "status": "error",
439 "stage": "trial_error",
440 "trial_number": trial.number,
441 "params": params,
442 "error": str(e),
443 "trials_completed": trial.number,
444 "total_trials": self.n_trials,
445 },
446 )
448 return float("-inf") # Return a very low score for failed trials
450 def _run_experiment(self, params: Dict[str, Any]) -> Dict[str, Any]:
451 """
452 Run a single experiment with the given parameters.
454 Args:
455 params: Dictionary of parameters to test
457 Returns:
458 Results dictionary with metrics and score
459 """
460 # Extract parameters
461 iterations = params.get("iterations", 2)
462 questions_per_iteration = params.get("questions_per_iteration", 2)
463 search_strategy = params.get("search_strategy", "source-based")
464 max_results = params.get("max_results", 50)
466 # Initialize profiling tools
467 speed_profiler = SpeedProfiler()
469 # Start profiling
470 speed_profiler.start()
472 try:
473 # Create system configuration
474 system_config = {
475 "iterations": iterations,
476 "questions_per_iteration": questions_per_iteration,
477 "search_strategy": search_strategy,
478 "search_tool": self.search_tool,
479 "max_results": max_results,
480 "model_name": self.model_name,
481 "provider": self.provider,
482 }
484 # Evaluate quality using composite benchmark evaluator
485 # Use a small number of examples for efficiency
486 benchmark_dir = str(Path(self.output_dir) / "benchmark_temp")
487 quality_results = self.benchmark_evaluator.evaluate(
488 system_config=system_config,
489 num_examples=5, # Small number for optimization efficiency
490 output_dir=benchmark_dir,
491 )
493 # Stop timing
494 speed_profiler.stop()
495 timing_results = speed_profiler.get_summary()
497 # Extract key metrics
498 quality_score = quality_results.get("quality_score", 0.0)
499 benchmark_results = quality_results.get("benchmark_results", {})
501 # Speed score: convert duration to a 0-1 score where faster is better
502 # Using a reasonable threshold (e.g., 180 seconds for 5 examples)
503 # Below this threshold: high score, above it: declining score
504 total_duration = timing_results.get("total_duration", 180)
505 speed_score = max(0.0, min(1.0, 1.0 - (total_duration - 60) / 180))
507 # Calculate combined score based on weights
508 combined_score = (
509 self.metric_weights.get("quality", 0.6) * quality_score
510 + self.metric_weights.get("speed", 0.4) * speed_score
511 )
513 # Return streamlined results
514 return {
515 "quality_score": quality_score,
516 "benchmark_results": benchmark_results,
517 "speed_score": speed_score,
518 "total_duration": total_duration,
519 "score": combined_score,
520 "success": True,
521 }
523 except Exception as e:
524 # Stop profiling on error
525 speed_profiler.stop()
527 # Log error
528 logger.exception("Error in experiment")
530 # Return error information
531 return {"error": str(e), "score": 0.0, "success": False}
533 def _optimization_callback(self, study: optuna.Study, trial: optuna.Trial):
534 """
535 Callback for the Optuna optimization process.
537 Args:
538 study: Optuna study object
539 trial: Current trial
540 """
541 # Save intermediate results periodically
542 if trial.number % 10 == 0 and trial.number > 0:
543 self._save_results()
544 self._create_quick_visualizations()
546 def _save_results(self):
547 """Save the optimization results to disk."""
548 # Create a timestamp for filenames
549 timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S")
551 # Save trial history
552 from ...security.file_write_verifier import write_json_verified
554 history_file = str(
555 Path(self.output_dir) / f"{self.study_name}_history.json"
556 )
558 # Convert numpy values to native Python types for JSON serialization
559 clean_history: List[Dict[str, Any]] = []
560 for trial in self.trials_history:
561 clean_trial: Dict[str, Any] = {}
562 for k, v in trial.items():
563 if isinstance(v, dict):
564 clean_trial[k] = {
565 dk: (float(dv) if isinstance(dv, np.number) else dv)
566 for dk, dv in v.items()
567 }
568 elif isinstance(v, np.number):
569 clean_trial[k] = float(v)
570 else:
571 clean_trial[k] = v
572 clean_history.append(clean_trial)
574 # Sanitize sensitive data before writing to disk
575 sanitized_history = sanitize_data(clean_history)
577 write_json_verified(
578 history_file,
579 sanitized_history,
580 "benchmark.allow_file_output",
581 context="optimization history",
582 )
584 # Save current best parameters
585 if (
586 self.study
587 and hasattr(self.study, "best_params")
588 and self.study.best_params
589 ):
590 best_params_file = str(
591 Path(self.output_dir) / f"{self.study_name}_best_params.json"
592 )
594 best_params_data = {
595 "best_params": self.study.best_params,
596 "best_value": float(self.study.best_value),
597 "n_trials": len(self.study.trials),
598 "timestamp": timestamp,
599 "base_query": self.base_query,
600 "model_name": self.model_name,
601 "provider": self.provider,
602 "search_tool": self.search_tool,
603 "metric_weights": self.metric_weights,
604 "benchmark_weights": self.benchmark_weights,
605 }
607 # Sanitize sensitive data before writing to disk
608 sanitized_best_params = sanitize_data(best_params_data)
610 write_json_verified(
611 best_params_file,
612 sanitized_best_params,
613 "benchmark.allow_file_output",
614 context="optimization best params",
615 )
617 # Save the Optuna study
618 if self.study:
619 study_file = str(
620 Path(self.output_dir) / f"{self.study_name}_study.pkl"
621 )
622 joblib.dump(self.study, study_file)
624 logger.info(f"Results saved to {self.output_dir}")
626 def _create_visualizations(self):
627 """Create and save comprehensive visualizations of the optimization results."""
628 if not PLOTTING_AVAILABLE:
629 logger.warning(
630 "Matplotlib not available, skipping visualization creation"
631 )
632 return
633 _ensure_plotting_loaded()
635 if not self.study or len(self.study.trials) < 2:
636 logger.warning("Not enough trials to create visualizations")
637 return
639 # Create directory for visualizations
640 _viz_dir_path = Path(self.output_dir) / "visualizations"
641 _viz_dir_path.mkdir(parents=True, exist_ok=True)
642 viz_dir = str(_viz_dir_path)
644 # Create Optuna visualizations
645 self._create_optuna_visualizations(viz_dir)
647 # Create custom visualizations
648 self._create_custom_visualizations(viz_dir)
650 logger.info(f"Visualizations saved to {viz_dir}")
652 def _create_quick_visualizations(self):
653 """Create a smaller set of visualizations for intermediate progress."""
654 if (
655 not PLOTTING_AVAILABLE
656 or not self.study
657 or len(self.study.trials) < 2
658 ):
659 return
660 _ensure_plotting_loaded()
662 # Create directory for visualizations
663 _quick_viz_dir_path = Path(self.output_dir) / "visualizations"
664 _quick_viz_dir_path.mkdir(parents=True, exist_ok=True)
665 viz_dir = str(_quick_viz_dir_path)
667 # Create optimization history only (faster than full visualization)
668 try:
669 fig = plot_optimization_history(self.study)
670 fig.write_image(
671 str(
672 Path(viz_dir)
673 / f"{self.study_name}_optimization_history_current.png"
674 )
675 )
676 except Exception:
677 logger.exception("Error creating optimization history plot")
679 def _create_optuna_visualizations(self, viz_dir: str):
680 """
681 Create and save Optuna's built-in visualizations.
683 Args:
684 viz_dir: Directory to save visualizations
685 """
686 if not self.study or not PLOTTING_AVAILABLE: 686 ↛ 687line 686 didn't jump to line 687 because the condition on line 686 was never true
687 return
688 _ensure_plotting_loaded()
689 study = self.study
690 timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S")
692 # 1. Optimization history
693 try:
694 fig = plot_optimization_history(study)
695 fig.write_image(
696 str(
697 Path(viz_dir)
698 / f"{self.study_name}_optimization_history_{timestamp}.png"
699 )
700 )
701 except Exception:
702 logger.exception("Error creating optimization history plot")
704 # 2. Parameter importances
705 try:
706 fig = plot_param_importances(study)
707 fig.write_image(
708 str(
709 Path(viz_dir)
710 / f"{self.study_name}_param_importances_{timestamp}.png"
711 )
712 )
713 except Exception:
714 logger.exception("Error creating parameter importances plot")
716 # 3. Slice plot for each parameter
717 try:
718 for param_name in study.best_params.keys():
719 fig = plot_slice(study, [param_name])
720 fig.write_image(
721 str(
722 Path(viz_dir)
723 / f"{self.study_name}_slice_{param_name}_{timestamp}.png"
724 )
725 )
726 except Exception:
727 logger.exception("Error creating slice plots")
729 # 4. Contour plots for important parameter pairs
730 try:
731 # Get all parameter names
732 param_names = list(study.best_params.keys())
734 # Create contour plots for each pair
735 for i in range(len(param_names)):
736 for j in range(i + 1, len(param_names)): 736 ↛ 737line 736 didn't jump to line 737 because the loop on line 736 never started
737 try:
738 fig = plot_contour(
739 study, params=[param_names[i], param_names[j]]
740 )
741 fig.write_image(
742 str(
743 Path(viz_dir)
744 / f"{self.study_name}_contour_{param_names[i]}_{param_names[j]}_{timestamp}.png"
745 )
746 )
747 except Exception:
748 logger.warning(
749 f"Error creating contour plot for {param_names[i]} vs {param_names[j]}"
750 )
751 except Exception:
752 logger.exception("Error creating contour plots")
754 def _create_custom_visualizations(self, viz_dir: str):
755 """
756 Create custom visualizations based on trial history.
758 Args:
759 viz_dir: Directory to save visualizations
760 """
761 if not self.trials_history:
762 return
764 timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S")
766 # Create quality vs speed plot
767 self._create_quality_vs_speed_plot(viz_dir, timestamp)
769 # Create parameter evolution plots
770 self._create_parameter_evolution_plots(viz_dir, timestamp)
772 # Create trial duration vs score plot
773 self._create_duration_vs_score_plot(viz_dir, timestamp)
775 def _create_quality_vs_speed_plot(self, viz_dir: str, timestamp: str):
776 """Create a plot showing quality vs. speed trade-off."""
777 if not self.trials_history: 777 ↛ 778line 777 didn't jump to line 778 because the condition on line 777 was never true
778 return
780 # Extract data from successful trials
781 successful_trials = [
782 t
783 for t in self.trials_history
784 if t.get("result", {}).get("success", False)
785 ]
787 if not successful_trials:
788 logger.warning("No successful trials for visualization")
789 return
791 try:
792 plt.figure(figsize=(10, 8))
794 # Extract metrics
795 quality_scores = []
796 speed_scores = []
797 labels = []
798 iterations_values = []
799 questions_values = []
801 for trial in successful_trials: 801 ↛ 815line 801 didn't jump to line 815 because the loop on line 801 didn't complete
802 result = trial["result"]
803 quality = result.get("quality_score", 0)
804 speed = result.get("speed_score", 0)
805 iterations = trial["params"].get("iterations", 0)
806 questions = trial["params"].get("questions_per_iteration", 0)
808 quality_scores.append(quality)
809 speed_scores.append(speed)
810 labels.append(f"Trial {trial['trial_number']}")
811 iterations_values.append(iterations)
812 questions_values.append(questions)
814 # Create scatter plot with size based on iterations*questions
815 sizes = [
816 i * q * 5
817 for i, q in zip(
818 iterations_values, questions_values, strict=False
819 )
820 ]
821 scatter = plt.scatter(
822 quality_scores,
823 speed_scores,
824 s=sizes,
825 alpha=0.7,
826 c=range(len(quality_scores)),
827 cmap="viridis",
828 )
830 # Highlight best trial
831 best_trial = max(
832 successful_trials,
833 key=lambda x: x.get("result", {}).get("score", 0),
834 )
835 best_quality = best_trial["result"].get("quality_score", 0)
836 best_speed = best_trial["result"].get("speed_score", 0)
837 best_iter = best_trial["params"].get("iterations", 0)
838 best_questions = best_trial["params"].get(
839 "questions_per_iteration", 0
840 )
842 plt.scatter(
843 [best_quality],
844 [best_speed],
845 s=200,
846 facecolors="none",
847 edgecolors="red",
848 linewidth=2,
849 label=f"Best: {best_iter}×{best_questions}",
850 )
852 # Add annotations for key points
853 for i, (q, s, label) in enumerate(
854 zip(quality_scores, speed_scores, labels, strict=False)
855 ):
856 if i % max(1, len(quality_scores) // 5) == 0: # Label ~5 points
857 plt.annotate(
858 f"{iterations_values[i]}×{questions_values[i]}",
859 (q, s),
860 xytext=(5, 5),
861 textcoords="offset points",
862 )
864 # Add colorbar and labels
865 cbar = plt.colorbar(scatter)
866 cbar.set_label("Trial Progression")
868 # Add benchmark weight information
869 weights_str = ", ".join(
870 [f"{k}:{v:.1f}" for k, v in self.benchmark_weights.items()]
871 )
872 plt.title(
873 f"Quality vs. Speed Trade-off\nBenchmark Weights: {weights_str}"
874 )
875 plt.xlabel("Quality Score (Benchmark Accuracy)")
876 plt.ylabel("Speed Score")
877 plt.grid(True, linestyle="--", alpha=0.7)
879 # Add legend explaining size
880 legend_elements = [
881 Line2D(
882 [0],
883 [0],
884 marker="o",
885 color="w",
886 markerfacecolor="gray",
887 markersize=np.sqrt(n * 5 / np.pi),
888 label=f"{n} Total Questions",
889 )
890 for n in [5, 10, 15, 20, 25]
891 ]
892 plt.legend(handles=legend_elements, title="Workload")
894 # Save the figure
895 plt.tight_layout()
896 plt.savefig(
897 str(
898 Path(viz_dir)
899 / f"{self.study_name}_quality_vs_speed_{timestamp}.png"
900 )
901 )
902 plt.close()
903 except Exception:
904 logger.exception("Error creating quality vs speed plot")
906 def _create_parameter_evolution_plots(self, viz_dir: str, timestamp: str):
907 """Create plots showing how parameter values evolve over trials."""
908 try:
909 successful_trials = [
910 t
911 for t in self.trials_history
912 if t.get("result", {}).get("success", False)
913 ]
915 if not successful_trials or len(successful_trials) < 5: 915 ↛ 919line 915 didn't jump to line 919 because the condition on line 915 was always true
916 return
918 # Get key parameters
919 main_params = list(successful_trials[0]["params"].keys())
921 # For each parameter, plot its values over trials
922 for param_name in main_params:
923 plt.figure(figsize=(12, 6))
925 trial_numbers = []
926 param_values = []
927 scores = []
929 for trial in self.trials_history:
930 if "params" in trial and param_name in trial["params"]:
931 trial_numbers.append(trial["trial_number"])
932 param_values.append(trial["params"][param_name])
933 scores.append(trial.get("score", 0))
935 # Create evolution plot
936 scatter = plt.scatter(
937 trial_numbers,
938 param_values,
939 c=scores,
940 cmap="plasma",
941 alpha=0.8,
942 s=80,
943 )
945 # Add best trial marker
946 best_trial_idx = scores.index(max(scores))
947 plt.scatter(
948 [trial_numbers[best_trial_idx]],
949 [param_values[best_trial_idx]],
950 s=150,
951 facecolors="none",
952 edgecolors="red",
953 linewidth=2,
954 label=f"Best Value: {param_values[best_trial_idx]}",
955 )
957 # Add colorbar
958 cbar = plt.colorbar(scatter)
959 cbar.set_label("Score")
961 # Set chart properties
962 plt.title(f"Evolution of {param_name} Values")
963 plt.xlabel("Trial Number")
964 plt.ylabel(param_name)
965 plt.grid(True, linestyle="--", alpha=0.7)
966 plt.legend()
968 # For categorical parameters, adjust y-axis
969 if isinstance(param_values[0], str):
970 unique_values = sorted(set(param_values))
971 plt.yticks(range(len(unique_values)), unique_values)
973 # Save the figure
974 plt.tight_layout()
975 plt.savefig(
976 str(
977 Path(viz_dir)
978 / f"{self.study_name}_param_evolution_{param_name}_{timestamp}.png"
979 )
980 )
981 plt.close()
982 except Exception:
983 logger.exception("Error creating parameter evolution plots")
985 def _create_duration_vs_score_plot(self, viz_dir: str, timestamp: str):
986 """Create a plot showing trial duration vs score."""
987 try:
988 plt.figure(figsize=(10, 6))
990 successful_trials = [
991 t
992 for t in self.trials_history
993 if t.get("result", {}).get("success", False)
994 ]
996 if not successful_trials: 996 ↛ 997line 996 didn't jump to line 997 because the condition on line 996 was never true
997 return
999 trial_durations = []
1000 trial_scores = []
1001 trial_iterations = []
1002 trial_questions = []
1004 for trial in successful_trials:
1005 duration = trial.get("duration", 0)
1006 score = trial.get("score", 0)
1007 iterations = trial.get("params", {}).get("iterations", 1)
1008 questions = trial.get("params", {}).get(
1009 "questions_per_iteration", 1
1010 )
1012 trial_durations.append(duration)
1013 trial_scores.append(score)
1014 trial_iterations.append(iterations)
1015 trial_questions.append(questions)
1017 # Total questions per trial
1018 total_questions = [
1019 i * q
1020 for i, q in zip(trial_iterations, trial_questions, strict=False)
1021 ]
1023 # Create scatter plot with size based on total questions
1024 plt.scatter(
1025 trial_durations,
1026 trial_scores,
1027 s=[
1028 q * 5 for q in total_questions
1029 ], # Size based on total questions
1030 alpha=0.7,
1031 c=range(len(trial_durations)),
1032 cmap="viridis",
1033 )
1035 # Add labels
1036 plt.xlabel("Trial Duration (seconds)")
1037 plt.ylabel("Score")
1038 plt.title("Trial Duration vs. Score")
1039 plt.grid(True, linestyle="--", alpha=0.7)
1041 # Add trial number annotations for selected points
1042 for i, (d, s) in enumerate(
1043 zip(trial_durations, trial_scores, strict=False)
1044 ):
1045 if ( 1045 ↛ 1042line 1045 didn't jump to line 1042 because the condition on line 1045 was always true
1046 i % max(1, len(trial_durations) // 5) == 0
1047 ): # Annotate ~5 points
1048 plt.annotate(
1049 f"{trial_iterations[i]}×{trial_questions[i]}",
1050 (d, s),
1051 xytext=(5, 5),
1052 textcoords="offset points",
1053 )
1055 # Save the figure
1056 plt.tight_layout()
1057 plt.savefig(
1058 str(
1059 Path(viz_dir)
1060 / f"{self.study_name}_duration_vs_score_{timestamp}.png"
1061 )
1062 )
1063 plt.close()
1064 except Exception:
1065 logger.exception("Error creating duration vs score plot")
1068def optimize_parameters(
1069 query: str,
1070 param_space: Optional[Dict[str, Any]] = None,
1071 output_dir: str = str(Path("data") / "optimization_results"),
1072 model_name: Optional[str] = None,
1073 provider: Optional[str] = None,
1074 search_tool: Optional[str] = None,
1075 temperature: float = 0.7,
1076 n_trials: int = 30,
1077 timeout: Optional[int] = None,
1078 n_jobs: int = 1,
1079 study_name: Optional[str] = None,
1080 optimization_metrics: Optional[List[str]] = None,
1081 metric_weights: Optional[Dict[str, float]] = None,
1082 progress_callback: Optional[Callable[[int, int, Dict], None]] = None,
1083 benchmark_weights: Optional[Dict[str, float]] = None,
1084) -> Tuple[Dict[str, Any], float]:
1085 """
1086 Optimize parameters for Local Deep Research.
1088 Args:
1089 query: The research query to use for all experiments
1090 param_space: Dictionary defining parameter search spaces (optional)
1091 output_dir: Directory to save optimization results
1092 model_name: Name of the LLM model to use
1093 provider: LLM provider
1094 search_tool: Search engine to use
1095 temperature: LLM temperature
1096 n_trials: Number of parameter combinations to try
1097 timeout: Maximum seconds to run optimization (None for no limit)
1098 n_jobs: Number of parallel jobs for optimization
1099 study_name: Name of the Optuna study
1100 optimization_metrics: List of metrics to optimize (default: ["quality", "speed"])
1101 metric_weights: Dictionary of weights for each metric (e.g., {"quality": 0.6, "speed": 0.4})
1102 progress_callback: Optional callback for progress updates
1103 benchmark_weights: Dictionary mapping benchmark types to weights
1104 (e.g., {"simpleqa": 0.6, "browsecomp": 0.4})
1105 If None, only SimpleQA is used with weight 1.0
1107 Returns:
1108 Tuple of (best_parameters, best_score)
1109 """
1110 # Create optimizer
1111 optimizer = OptunaOptimizer(
1112 base_query=query,
1113 output_dir=output_dir,
1114 model_name=model_name,
1115 provider=provider,
1116 search_tool=search_tool,
1117 temperature=temperature,
1118 n_trials=n_trials,
1119 timeout=timeout,
1120 n_jobs=n_jobs,
1121 study_name=study_name,
1122 optimization_metrics=optimization_metrics,
1123 metric_weights=metric_weights,
1124 progress_callback=progress_callback,
1125 benchmark_weights=benchmark_weights,
1126 )
1128 # Run optimization
1129 return optimizer.optimize(param_space)
1132def optimize_for_speed(
1133 query: str,
1134 n_trials: int = 20,
1135 output_dir: str = str(Path("data") / "optimization_results"),
1136 model_name: Optional[str] = None,
1137 provider: Optional[str] = None,
1138 search_tool: Optional[str] = None,
1139 progress_callback: Optional[Callable[[int, int, Dict], None]] = None,
1140 benchmark_weights: Optional[Dict[str, float]] = None,
1141) -> Tuple[Dict[str, Any], float]:
1142 """
1143 Optimize parameters with a focus on speed performance.
1145 Args:
1146 query: The research query to use for all experiments
1147 n_trials: Number of parameter combinations to try
1148 output_dir: Directory to save optimization results
1149 model_name: Name of the LLM model to use
1150 provider: LLM provider
1151 search_tool: Search engine to use
1152 progress_callback: Optional callback for progress updates
1153 benchmark_weights: Dictionary mapping benchmark types to weights
1154 (e.g., {"simpleqa": 0.6, "browsecomp": 0.4})
1155 If None, only SimpleQA is used with weight 1.0
1157 Returns:
1158 Tuple of (best_parameters, best_score)
1159 """
1160 # Focus on speed with reduced parameter space
1161 param_space = {
1162 "iterations": {
1163 "type": "int",
1164 "low": 1,
1165 "high": 3,
1166 "step": 1,
1167 },
1168 "questions_per_iteration": {
1169 "type": "int",
1170 "low": 1,
1171 "high": 3,
1172 "step": 1,
1173 },
1174 "search_strategy": {
1175 "type": "categorical",
1176 "choices": ["source-based", "focused-iteration"],
1177 },
1178 }
1180 # Speed-focused weights
1181 metric_weights = {"speed": 0.8, "quality": 0.2}
1183 return optimize_parameters(
1184 query=query,
1185 param_space=param_space,
1186 output_dir=output_dir,
1187 model_name=model_name,
1188 provider=provider,
1189 search_tool=search_tool,
1190 n_trials=n_trials,
1191 metric_weights=metric_weights,
1192 optimization_metrics=["speed", "quality"],
1193 progress_callback=progress_callback,
1194 benchmark_weights=benchmark_weights,
1195 )
1198def optimize_for_quality(
1199 query: str,
1200 n_trials: int = 30,
1201 output_dir: str = str(Path("data") / "optimization_results"),
1202 model_name: Optional[str] = None,
1203 provider: Optional[str] = None,
1204 search_tool: Optional[str] = None,
1205 progress_callback: Optional[Callable[[int, int, Dict], None]] = None,
1206 benchmark_weights: Optional[Dict[str, float]] = None,
1207) -> Tuple[Dict[str, Any], float]:
1208 """
1209 Optimize parameters with a focus on result quality.
1211 Args:
1212 query: The research query to use for all experiments
1213 n_trials: Number of parameter combinations to try
1214 output_dir: Directory to save optimization results
1215 model_name: Name of the LLM model to use
1216 provider: LLM provider
1217 search_tool: Search engine to use
1218 progress_callback: Optional callback for progress updates
1219 benchmark_weights: Dictionary mapping benchmark types to weights
1220 (e.g., {"simpleqa": 0.6, "browsecomp": 0.4})
1221 If None, only SimpleQA is used with weight 1.0
1223 Returns:
1224 Tuple of (best_parameters, best_score)
1225 """
1226 # Quality-focused weights
1227 metric_weights = {"quality": 0.9, "speed": 0.1}
1229 return optimize_parameters(
1230 query=query,
1231 output_dir=output_dir,
1232 model_name=model_name,
1233 provider=provider,
1234 search_tool=search_tool,
1235 n_trials=n_trials,
1236 metric_weights=metric_weights,
1237 optimization_metrics=["quality", "speed"],
1238 progress_callback=progress_callback,
1239 benchmark_weights=benchmark_weights,
1240 )
1243def optimize_for_efficiency(
1244 query: str,
1245 n_trials: int = 25,
1246 output_dir: str = str(Path("data") / "optimization_results"),
1247 model_name: Optional[str] = None,
1248 provider: Optional[str] = None,
1249 search_tool: Optional[str] = None,
1250 progress_callback: Optional[Callable[[int, int, Dict], None]] = None,
1251 benchmark_weights: Optional[Dict[str, float]] = None,
1252) -> Tuple[Dict[str, Any], float]:
1253 """
1254 Optimize parameters with a focus on resource efficiency.
1256 Args:
1257 query: The research query to use for all experiments
1258 n_trials: Number of parameter combinations to try
1259 output_dir: Directory to save optimization results
1260 model_name: Name of the LLM model to use
1261 provider: LLM provider
1262 search_tool: Search engine to use
1263 progress_callback: Optional callback for progress updates
1264 benchmark_weights: Dictionary mapping benchmark types to weights
1265 (e.g., {"simpleqa": 0.6, "browsecomp": 0.4})
1266 If None, only SimpleQA is used with weight 1.0
1268 Returns:
1269 Tuple of (best_parameters, best_score)
1270 """
1271 # Balance of quality, speed and resource usage
1272 metric_weights = {"quality": 0.4, "speed": 0.3, "resource": 0.3}
1274 return optimize_parameters(
1275 query=query,
1276 output_dir=output_dir,
1277 model_name=model_name,
1278 provider=provider,
1279 search_tool=search_tool,
1280 n_trials=n_trials,
1281 metric_weights=metric_weights,
1282 optimization_metrics=["quality", "speed", "resource"],
1283 progress_callback=progress_callback,
1284 benchmark_weights=benchmark_weights,
1285 )