Skip to content

Commit 190058b

Browse files
committed
refactor powell's method
1 parent 93fc99f commit 190058b

File tree

7 files changed

+663
-301
lines changed

7 files changed

+663
-301
lines changed

src/gradient_free_optimizers/optimizers/global_opt/powells_method/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,19 @@
55

66
from .powells_method import PowellsMethod
77
from .direction import Direction
8+
from .line_search import (
9+
LineSearch,
10+
GridLineSearch,
11+
GoldenSectionLineSearch,
12+
HillClimbLineSearch,
13+
)
814

915

1016
__all__ = [
1117
"PowellsMethod",
1218
"Direction",
19+
"LineSearch",
20+
"GridLineSearch",
21+
"GoldenSectionLineSearch",
22+
"HillClimbLineSearch",
1323
]
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Author: Simon Blanke
2+
3+
# License: MIT License
4+
5+
from .base import LineSearch
6+
from .grid_search import GridLineSearch
7+
from .golden_section import GoldenSectionLineSearch
8+
from .hill_climb import HillClimbLineSearch
9+
10+
11+
__all__ = [
12+
"LineSearch",
13+
"GridLineSearch",
14+
"GoldenSectionLineSearch",
15+
"HillClimbLineSearch",
16+
]
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
# Author: Simon Blanke
2+
3+
# License: MIT License
4+
5+
from abc import ABC, abstractmethod
6+
from typing import Optional, Tuple
7+
8+
import numpy as np
9+
10+
11+
class LineSearch(ABC):
12+
"""
13+
Abstract base class for line search strategies in Powell's method.
14+
15+
A line search finds the optimum along a given direction from a starting point.
16+
Different strategies (grid, golden section, hill climbing) implement this
17+
interface to provide various trade-offs between exploration and efficiency.
18+
"""
19+
20+
def __init__(self, optimizer):
21+
"""
22+
Initialize the line search strategy.
23+
24+
Parameters
25+
----------
26+
optimizer : PowellsMethod
27+
Reference to the parent optimizer for accessing converter and utilities.
28+
"""
29+
self.optimizer = optimizer
30+
31+
@abstractmethod
32+
def start(
33+
self,
34+
origin: np.ndarray,
35+
direction: np.ndarray,
36+
max_iters: int,
37+
) -> None:
38+
"""
39+
Initialize a new line search along a direction.
40+
41+
Parameters
42+
----------
43+
origin : np.ndarray
44+
Starting position in search space (integer indices).
45+
direction : np.ndarray
46+
Normalized direction vector to search along.
47+
max_iters : int
48+
Maximum number of evaluations for this line search.
49+
"""
50+
pass
51+
52+
@abstractmethod
53+
def get_next_position(self) -> Optional[np.ndarray]:
54+
"""
55+
Get the next position to evaluate.
56+
57+
Returns
58+
-------
59+
Optional[np.ndarray]
60+
Next position to evaluate, or None if line search is complete.
61+
"""
62+
pass
63+
64+
@abstractmethod
65+
def update(self, position: np.ndarray, score: float) -> None:
66+
"""
67+
Update the line search state after an evaluation.
68+
69+
Parameters
70+
----------
71+
position : np.ndarray
72+
The position that was evaluated.
73+
score : float
74+
The score obtained at that position.
75+
"""
76+
pass
77+
78+
@abstractmethod
79+
def get_best_result(self) -> Tuple[Optional[np.ndarray], Optional[float]]:
80+
"""
81+
Get the best result found during this line search.
82+
83+
Returns
84+
-------
85+
Tuple[Optional[np.ndarray], Optional[float]]
86+
(best_position, best_score) or (None, None) if no valid results.
87+
"""
88+
pass
89+
90+
@abstractmethod
91+
def is_active(self) -> bool:
92+
"""
93+
Check if the line search is still in progress.
94+
95+
Returns
96+
-------
97+
bool
98+
True if more evaluations are needed, False if complete.
99+
"""
100+
pass
101+
102+
def _compute_max_step(self, origin: np.ndarray, direction: np.ndarray) -> float:
103+
"""
104+
Compute the maximum step size along a direction that stays within bounds.
105+
106+
Parameters
107+
----------
108+
origin : np.ndarray
109+
Starting position.
110+
direction : np.ndarray
111+
Normalized direction vector.
112+
113+
Returns
114+
-------
115+
float
116+
Maximum step size (for symmetric bounds [-max_t, max_t]).
117+
"""
118+
max_t_positive = float("inf")
119+
max_t_negative = float("inf")
120+
121+
max_positions = self.optimizer.conv.max_positions
122+
123+
for i, (d, o, max_pos) in enumerate(zip(direction, origin, max_positions)):
124+
if abs(d) < 1e-10:
125+
continue
126+
127+
if d > 0:
128+
t_to_max = (max_pos - o) / d
129+
t_to_zero = -o / d
130+
max_t_positive = min(max_t_positive, t_to_max)
131+
max_t_negative = min(max_t_negative, -t_to_zero)
132+
else:
133+
t_to_zero = -o / d
134+
t_to_max = (max_pos - o) / d
135+
max_t_positive = min(max_t_positive, t_to_zero)
136+
max_t_negative = min(max_t_negative, -t_to_max)
137+
138+
max_t = min(max_t_positive, max_t_negative)
139+
140+
if max_t < 1:
141+
max_t = max(max_positions) * 0.5
142+
143+
return max_t
144+
145+
def _snap_to_grid(self, position: np.ndarray) -> np.ndarray:
146+
"""
147+
Snap a floating-point position to valid grid indices.
148+
149+
Parameters
150+
----------
151+
position : np.ndarray
152+
Position with potentially non-integer values.
153+
154+
Returns
155+
-------
156+
np.ndarray
157+
Valid integer position within search space bounds.
158+
"""
159+
return self.optimizer.conv2pos(position)
160+
161+
def _is_valid(self, position: np.ndarray) -> bool:
162+
"""
163+
Check if a position satisfies constraints.
164+
165+
Parameters
166+
----------
167+
position : np.ndarray
168+
Position to check.
169+
170+
Returns
171+
-------
172+
bool
173+
True if position is valid (not in constraint).
174+
"""
175+
return self.optimizer.conv.not_in_constraint(position)
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
# Author: Simon Blanke
2+
3+
# License: MIT License
4+
5+
from typing import Optional, Tuple, List
6+
7+
import numpy as np
8+
9+
from .base import LineSearch
10+
11+
12+
# Golden ratio: (sqrt(5) - 1) / 2 ≈ 0.618
13+
GOLDEN_RATIO = (np.sqrt(5) - 1) / 2
14+
15+
16+
class GoldenSectionLineSearch(LineSearch):
17+
"""
18+
Golden section search strategy for line search.
19+
20+
Uses the golden ratio to efficiently narrow down the bracket containing
21+
the optimum. More efficient than grid search for unimodal functions,
22+
requiring O(log(n)) evaluations to achieve precision n.
23+
"""
24+
25+
def __init__(self, optimizer):
26+
super().__init__(optimizer)
27+
self.origin: Optional[np.ndarray] = None
28+
self.direction: Optional[np.ndarray] = None
29+
self.max_iters: int = 0
30+
self.current_step: int = 0
31+
self.active: bool = False
32+
33+
# Bracket state: a < c < d < b
34+
self.a: float = 0.0
35+
self.b: float = 0.0
36+
self.c: float = 0.0
37+
self.d: float = 0.0
38+
self.fc: Optional[float] = None
39+
self.fd: Optional[float] = None
40+
self.phase: str = "eval_c"
41+
42+
# Track all evaluations for best result
43+
self.evaluated_positions: List[np.ndarray] = []
44+
self.evaluated_scores: List[float] = []
45+
46+
def start(
47+
self,
48+
origin: np.ndarray,
49+
direction: np.ndarray,
50+
max_iters: int,
51+
) -> None:
52+
"""Initialize golden section search with bracket [a, b]."""
53+
self.origin = origin.copy()
54+
self.direction = direction.copy()
55+
self.max_iters = max_iters
56+
self.current_step = 0
57+
self.active = True
58+
59+
self.evaluated_positions = []
60+
self.evaluated_scores = []
61+
62+
# Compute bracket bounds
63+
max_t = self._compute_max_step(origin, direction)
64+
65+
# Initialize bracket: a < c < d < b
66+
self.a = -max_t
67+
self.b = max_t
68+
# c at ~38.2% of interval, d at ~61.8% of interval
69+
self.c = self.a + (1 - GOLDEN_RATIO) * (self.b - self.a)
70+
self.d = self.a + GOLDEN_RATIO * (self.b - self.a)
71+
72+
self.fc = None
73+
self.fd = None
74+
self.phase = "eval_c"
75+
76+
def get_next_position(self) -> Optional[np.ndarray]:
77+
"""Return the next position to evaluate based on current phase."""
78+
if not self.active or self.current_step >= self.max_iters:
79+
self.active = False
80+
return None
81+
82+
if self.phase == "eval_c":
83+
t = self.c
84+
elif self.phase == "eval_d":
85+
t = self.d
86+
else:
87+
self.active = False
88+
return None
89+
90+
pos_float = self.origin + t * self.direction
91+
pos = self._snap_to_grid(pos_float)
92+
return pos
93+
94+
def update(self, position: np.ndarray, score: float) -> None:
95+
"""Update bracket based on evaluation result."""
96+
self.evaluated_positions.append(position.copy())
97+
self.evaluated_scores.append(score)
98+
99+
if self.phase == "eval_c":
100+
self.fc = score
101+
if self.fd is None:
102+
# First time: need to evaluate d next
103+
self.phase = "eval_d"
104+
else:
105+
# Both c and d evaluated: narrow bracket
106+
self._narrow_bracket()
107+
elif self.phase == "eval_d":
108+
self.fd = score
109+
if self.fc is None:
110+
# Need to evaluate c next
111+
self.phase = "eval_c"
112+
else:
113+
# Both c and d evaluated: narrow bracket
114+
self._narrow_bracket()
115+
116+
def _narrow_bracket(self) -> None:
117+
"""
118+
Narrow the bracket based on function values at c and d.
119+
120+
For MAXIMIZATION:
121+
- If f(c) > f(d): maximum in [a, d], narrow right
122+
- If f(d) >= f(c): maximum in [c, b], narrow left
123+
"""
124+
if self.fc > self.fd:
125+
# Maximum in [a, d], narrow from right
126+
# Reuse c as new d
127+
self.b = self.d
128+
self.d = self.c
129+
self.fd = self.fc
130+
# Calculate new c
131+
self.c = self.a + (1 - GOLDEN_RATIO) * (self.b - self.a)
132+
self.fc = None
133+
self.phase = "eval_c"
134+
else:
135+
# Maximum in [c, b], narrow from left
136+
# Reuse d as new c
137+
self.a = self.c
138+
self.c = self.d
139+
self.fc = self.fd
140+
# Calculate new d
141+
self.d = self.a + GOLDEN_RATIO * (self.b - self.a)
142+
self.fd = None
143+
self.phase = "eval_d"
144+
145+
self.current_step += 1
146+
147+
def get_best_result(self) -> Tuple[Optional[np.ndarray], Optional[float]]:
148+
"""Return the position with the highest score."""
149+
if not self.evaluated_scores:
150+
return None, None
151+
152+
best_idx = np.argmax(self.evaluated_scores)
153+
return self.evaluated_positions[best_idx], self.evaluated_scores[best_idx]
154+
155+
def is_active(self) -> bool:
156+
"""Check if golden section search should continue."""
157+
return self.active and self.current_step < self.max_iters

0 commit comments

Comments
 (0)