feat(ga): Implement Cauchy's bound for automatic root range detection #18

Merged
jono merged 1 commits from v0.4.2-dev into main 2025-10-27 18:36:41 +00:00
2 changed files with 52 additions and 5 deletions

View File

@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
[project]
# --- Core Metadata ---
name = "polysolve"
version = "0.4.1"
version = "0.4.2"
authors = [
{ name="Jonathan Rampersad", email="jonathan@jono-rams.work" },
]

View File

@@ -90,6 +90,23 @@ class GA_Options:
"The number of returned solutions will be limited to data_size."
)
def _get_cauchy_bound(coeffs: np.ndarray) -> float:
"""
Calculates Cauchy's bound for the roots of a polynomial.
This provides a radius R such that all roots (real and complex)
have an absolute value less than or equal to R.
R = 1 + max(|c_n-1/c_n|, |c_n-2/c_n|, ..., |c_0/c_n|)
Where c_n is the leading coefficient (coeffs[0]).
"""
# Normalize all coefficients by the leading coefficient
normalized_coeffs = np.abs(coeffs[1:] / coeffs[0])
# The bound is 1 + the maximum of these normalized values
R = 1 + np.max(normalized_coeffs)
return R
class Function:
"""
Represents an exponential function (polynomial) of the form:
@@ -287,8 +304,23 @@ class Function:
mutation_size = int(data_size * mutation_ratio)
random_size = data_size - elite_size - crossover_size - mutation_size
# Check if the user is using the default, non-expert range
user_range_is_default = (options.min_range == -100.0 and options.max_range == 100.0)
if user_range_is_default:
# User hasn't specified a custom range.
# We are the expert; use the smart, guaranteed bound.
bound = _get_cauchy_bound(self.coefficients)
min_r = -bound
max_r = bound
else:
# User has provided a custom range.
# Trust the expert; use their range.
min_r = options.min_range
max_r = options.max_range
# Create initial random solutions
solutions = np.random.uniform(options.min_range, options.max_range, data_size)
solutions = np.random.uniform(min_r, max_r, data_size)
for _ in range(options.num_of_generations):
# Calculate fitness for all solutions (vectorized)
@@ -332,7 +364,7 @@ class Function:
mutated_solutions = mutation_candidates * mutation_factors
# 4. New Randoms: Add new blood to prevent getting stuck
random_solutions = np.random.uniform(options.min_range, options.max_range, random_size)
random_solutions = np.random.uniform(min_r, max_r, random_size)
# Assemble the new generation
solutions = np.concatenate([
@@ -373,9 +405,24 @@ class Function:
fitness_gpu = cupy.RawKernel(_FITNESS_KERNEL_FLOAT, 'fitness_kernel')
d_coefficients = cupy.array(self.coefficients, dtype=cupy.float64)
# Check if the user is using the default, non-expert range
user_range_is_default = (options.min_range == -100.0 and options.max_range == 100.0)
if user_range_is_default:
# User hasn't specified a custom range.
# We are the expert; use the smart, guaranteed bound.
bound = _get_cauchy_bound(self.coefficients)
min_r = -bound
max_r = bound
else:
# User has provided a custom range.
# Trust the expert; use their range.
min_r = options.min_range
max_r = options.max_range
# Create initial random solutions on the GPU
d_solutions = cupy.random.uniform(
options.min_range, options.max_range, options.data_size, dtype=cupy.float64
min_r, max_r, options.data_size, dtype=cupy.float64
)
d_ranks = cupy.empty(options.data_size, dtype=cupy.float64)
@@ -426,7 +473,7 @@ class Function:
# 4. New Randoms
d_random_solutions = cupy.random.uniform(
options.min_range, options.max_range, random_size, dtype=cupy.float64
min_r, max_r, random_size, dtype=cupy.float64
)
# Assemble the new generation