Files
PolySolve/src/polysolve/__init__.py
Jonathan Rampersad eff650bf2c
Some checks failed
Run Python Tests / test (3.12) (pull_request) Failing after 10s
Run Python Tests / test (3.10) (pull_request) Failing after 18s
Run Python Tests / test (3.8) (pull_request) Failing after 11s
feat(ga): Implement quality filtering and precision-based clustering
The previous GA logic was returning the "top N" solutions, which led to test failures when the algorithm correctly converged on only one of all possible roots (e.g., returning 1000 variations of -1.0).

This commit fixes the root-finding logic to correctly identify and return *all* unique, high-quality roots:

1.  **feat(api):** Adds `root_precision` to `GA_Options`. This new parameter (default: 5) allows the user to control the number of decimal places for clustering unique roots.

2.  **fix(ga):** Replaces the flawed "top N" logic in both `_solve_x_numpy` and `_solve_x_cuda`. The new process is:
    * Dynamically sets a `quality_threshold` based on the user's `root_precision` (e.g., `precision=5` requires a rank > `1e6`).
    * Filters the *entire* final population for all solutions that meet this quality threshold.
    * Rounds these high-quality solutions to `root_precision`.
    * Returns only the `np.unique()` results.

This ensures the solver returns all distinct roots that meet the accuracy requirements, rather than just the top N variations of a single root.
2025-10-27 15:13:20 -04:00

766 lines
28 KiB
Python

import math
import numpy as np
from dataclasses import dataclass
from typing import List, Optional, Union
import warnings
# Attempt to import CuPy for CUDA acceleration.
# If CuPy is not installed, the CUDA functionality will not be available.
try:
import cupy
_CUPY_AVAILABLE = True
except ImportError:
_CUPY_AVAILABLE = False
# The CUDA kernels for the fitness function
_FITNESS_KERNEL_FLOAT = """
extern "C" __global__ void fitness_kernel(
const double* coefficients,
int num_coefficients,
const double* x_vals,
double* ranks,
int size,
double y_val)
{
int idx = threadIdx.x + blockIdx.x * blockDim.x;
if (idx < size)
{
double ans = coefficients[0];
for (int i = 1; i < num_coefficients; ++i)
{
ans = ans * x_vals[idx] + coefficients[i];
}
ans -= y_val;
ranks[idx] = (ans == 0) ? 1.7976931348623157e+308 : fabs(1.0 / ans);
}
}
"""
@dataclass
class GA_Options:
"""
Configuration options for the genetic algorithm used to find function roots.
Attributes:
min_range (float): The minimum value for the initial random solutions.
Default: -100.0
max_range (float): The maximum value for the initial random solutions.
Default: 100.0
num_of_generations (int): The number of iterations the algorithm will run.
Default: 10
data_size (int): The total number of solutions (population size)
generated in each generation. Default: 100000
mutation_strength (float): The percentage (e.g., 0.01 for 1%) by which
a solution is mutated. Default: 0.01
elite_ratio (float): The percentage (e.g., 0.05 for 5%) of the *best*
solutions to carry over to the next generation
unchanged (elitism). Default: 0.05
crossover_ratio (float): The percentage (e.g., 0.45 for 45%) of the next
generation to be created by "breeding" two
solutions from the parent pool. Default: 0.45
mutation_ratio (float): The percentage (e.g., 0.40 for 40%) of the next
generation to be created by mutating solutions
from the parent pool. Default: 0.40
root_precision (int): The number of decimal places to round roots to
when clustering. A smaller number (e.g., 3)
groups roots more aggressively. A larger number
(e.g., 7) is more precise but may return
multiple near-identical roots. Default: 5
"""
min_range: float = -100.0
max_range: float = 100.0
num_of_generations: int = 10
data_size: int = 100000
mutation_strength: float = 0.01
elite_ratio: float = 0.05
crossover_ratio: float = 0.45
mutation_ratio: float = 0.40
root_precision: int = 5
def __post_init__(self):
"""Validates the GA options after initialization."""
total_ratio = self.elite_ratio + self.crossover_ratio + self.mutation_ratio
if total_ratio > 1.0:
raise ValueError(
f"The sum of elite_ratio, crossover_ratio, and mutation_ratio must be <= 1.0, but got {total_ratio}"
)
if any(r < 0 for r in [self.elite_ratio, self.crossover_ratio, self.mutation_ratio]):
raise ValueError("GA ratios cannot be negative.")
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:
c_0*x^n + c_1*x^(n-1) + ... + c_n
"""
def __init__(self, largest_exponent: int):
"""
Initializes a function with its highest degree.
Args:
largest_exponent (int): The largest exponent (n) in the function.
"""
if not isinstance(largest_exponent, int) or largest_exponent < 0:
raise ValueError("largest_exponent must be a non-negative integer.")
self._largest_exponent = largest_exponent
self.coefficients: Optional[np.ndarray] = None
self._initialized = False
def set_coeffs(self, coefficients: List[Union[int, float]]):
"""
Sets the coefficients of the polynomial.
Args:
coefficients (List[Union[int, float]]): A list of integer or float
coefficients. The list size
must be largest_exponent + 1.
Raises:
ValueError: If the input is invalid.
"""
expected_size = self._largest_exponent + 1
if len(coefficients) != expected_size:
raise ValueError(
f"Function with exponent {self._largest_exponent} requires {expected_size} coefficients, "
f"but {len(coefficients)} were given."
)
if coefficients[0] == 0 and self._largest_exponent > 0:
raise ValueError("The first constant (for the largest exponent) cannot be 0.")
# Check if any coefficient is a float
is_float = any(isinstance(c, float) for c in coefficients)
# Choose the dtype based on the input
target_dtype = np.float64 if is_float else np.int64
self.coefficients = np.array(coefficients, dtype=target_dtype)
self._initialized = True
def _check_initialized(self):
"""Raises a RuntimeError if the function coefficients have not been set."""
if not self._initialized:
raise RuntimeError("Function is not fully initialized. Call .set_coeffs() first.")
@property
def largest_exponent(self) -> int:
"""Returns the largest exponent of the function."""
return self._largest_exponent
@property
def degree(self) -> int:
"""Returns the largest exponent of the function."""
return self._largest_exponent
def solve_y(self, x_val: float) -> float:
"""
Solves for y given an x value. (i.e., evaluates the polynomial at x).
Args:
x_val (float): The x-value to evaluate.
Returns:
float: The resulting y-value.
"""
self._check_initialized()
return np.polyval(self.coefficients, x_val)
def differential(self) -> 'Function':
"""
Calculates the derivative of the function.
Returns:
Function: A new Function object representing the derivative.
"""
warnings.warn(
"The 'differential' function has been renamed. Please use 'derivative' instead.",
DeprecationWarning,
stacklevel=2
)
self._check_initialized()
if self._largest_exponent == 0:
raise ValueError("Cannot differentiate a constant (Function of degree 0).")
return self.derivative()
def derivative(self) -> 'Function':
"""
Calculates the derivative of the function.
Returns:
Function: A new Function object representing the derivative.
"""
self._check_initialized()
if self._largest_exponent == 0:
raise ValueError("Cannot differentiate a constant (Function of degree 0).")
derivative_coefficients = np.polyder(self.coefficients)
diff_func = Function(self._largest_exponent - 1)
diff_func.set_coeffs(derivative_coefficients.tolist())
return diff_func
def nth_derivative(self, n: int) -> 'Function':
"""
Calculates the nth derivative of the function.
Args:
n (int): The order of the derivative to calculate.
Returns:
Function: A new Function object representing the nth derivative.
"""
self._check_initialized()
if not isinstance(n, int) or n < 1:
raise ValueError("Derivative order 'n' must be a positive integer.")
if n > self.largest_exponent:
function = Function(0)
function.set_coeffs([0])
return function
if n == 1:
return self.derivative()
function = self
for _ in range(n):
function = function.derivative()
return function
def get_real_roots(self, options: GA_Options = GA_Options(), use_cuda: bool = False) -> np.ndarray:
"""
Uses a genetic algorithm to find the approximate real roots of the function (where y=0).
Args:
options (GA_Options): Configuration for the genetic algorithm.
use_cuda (bool): If True, attempts to use CUDA for acceleration.
Returns:
np.ndarray: An array of approximate root values.
"""
self._check_initialized()
return self.solve_x(0.0, options, use_cuda)
def solve_x(self, y_val: float, options: GA_Options = GA_Options(), use_cuda: bool = False) -> np.ndarray:
"""
Uses a genetic algorithm to find x-values for a given y-value.
Args:
y_val (float): The target y-value.
options (GA_Options): Configuration for the genetic algorithm.
use_cuda (bool): If True, attempts to use CUDA for acceleration.
Returns:
np.ndarray: An array of approximate x-values.
"""
self._check_initialized()
if use_cuda and _CUPY_AVAILABLE:
return self._solve_x_cuda(y_val, options)
else:
if use_cuda:
warnings.warn(
"use_cuda=True was specified, but CuPy is not installed. "
"Falling back to NumPy (CPU). For GPU acceleration, "
"install with 'pip install polysolve[cuda]'.",
UserWarning
)
return self._solve_x_numpy(y_val, options)
def _solve_x_numpy(self, y_val: float, options: GA_Options) -> np.ndarray:
"""Genetic algorithm implementation using NumPy (CPU)."""
elite_ratio = options.elite_ratio
crossover_ratio = options.crossover_ratio
mutation_ratio = options.mutation_ratio
data_size = options.data_size
elite_size = int(data_size * elite_ratio)
crossover_size = int(data_size * crossover_ratio)
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(min_r, max_r, data_size)
for _ in range(options.num_of_generations):
# Calculate fitness for all solutions (vectorized)
y_calculated = np.polyval(self.coefficients, solutions)
error = y_calculated - y_val
with np.errstate(divide='ignore'):
ranks = np.where(error == 0, np.finfo(float).max, np.abs(1.0 / error))
# Sort solutions by fitness (descending)
sorted_indices = np.argsort(-ranks)
solutions = solutions[sorted_indices]
# --- Create the next generation ---
# 1. Elitism: Keep the best solutions as-is
elite_solutions = solutions[:elite_size]
# Define a "parent pool" of the top 50% of solutions to breed from
parent_pool = solutions[:data_size // 2]
# 2. Crossover: Breed two parents to create a child
# Select from the full list (indices 0 to data_size-1)
parent1_indices = np.random.randint(0, data_size, crossover_size)
parent2_indices = np.random.randint(0, data_size, crossover_size)
parents1 = solutions[parent1_indices]
parents2 = solutions[parent2_indices]
# Simple "average" crossover
crossover_solutions = (parents1 + parents2) / 2.0
# 3. Mutation:
# Select from the full list (indices 0 to data_size-1)
mutation_candidates = solutions[np.random.randint(0, data_size, mutation_size)]
# Use mutation_strength (the new name)
mutation_factors = np.random.uniform(
1 - options.mutation_strength,
1 + options.mutation_strength,
mutation_size
)
mutated_solutions = mutation_candidates * mutation_factors
# 4. New Randoms: Add new blood to prevent getting stuck
random_solutions = np.random.uniform(min_r, max_r, random_size)
# Assemble the new generation
solutions = np.concatenate([
elite_solutions,
crossover_solutions,
mutated_solutions,
random_solutions
])
# --- Final Step: Return the best results ---
# After all generations, do one last ranking to find the best solutions
y_calculated = np.polyval(self.coefficients, solutions)
error = y_calculated - y_val
with np.errstate(divide='ignore'):
ranks = np.where(error == 0, np.finfo(float).max, np.abs(1.0 / error))
# 1. Define quality based on the user's desired precision
# (e.g., precision=5 -> rank > 1e6, precision=8 -> rank > 1e9)
# We add +1 for a buffer, ensuring we only get high-quality roots.
quality_threshold = 10**(options.root_precision + 1)
# 2. Get all solutions that meet this quality threshold
high_quality_solutions = solutions[ranks > quality_threshold]
if high_quality_solutions.size == 0:
# No roots found that meet the quality, return empty
return np.array([])
# 3. Cluster these high-quality solutions by rounding
rounded_solutions = np.round(high_quality_solutions, options.root_precision)
# 4. Return only the unique roots
unique_roots = np.unique(rounded_solutions)
return np.sort(unique_roots)
def _solve_x_cuda(self, y_val: float, options: GA_Options) -> np.ndarray:
"""Genetic algorithm implementation using CuPy (GPU/CUDA)."""
elite_ratio = options.elite_ratio
crossover_ratio = options.crossover_ratio
mutation_ratio = options.mutation_ratio
data_size = options.data_size
elite_size = int(data_size * elite_ratio)
crossover_size = int(data_size * crossover_ratio)
mutation_size = int(data_size * mutation_ratio)
random_size = data_size - elite_size - crossover_size - mutation_size
# ALWAYS cast coefficients to float64 for the kernel.
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(
min_r, max_r, options.data_size, dtype=cupy.float64
)
d_ranks = cupy.empty(options.data_size, dtype=cupy.float64)
# Configure kernel launch parameters
threads_per_block = 512
blocks_per_grid = (options.data_size + threads_per_block - 1) // threads_per_block
for i in range(options.num_of_generations):
# Run the fitness kernel on the GPU
fitness_gpu(
(blocks_per_grid,), (threads_per_block,),
(d_coefficients, d_coefficients.size, d_solutions, d_ranks, d_solutions.size, y_val)
)
# Sort solutions by rank on the GPU
sorted_indices = cupy.argsort(-d_ranks)
d_solutions = d_solutions[sorted_indices]
# --- Create the next generation ---
# 1. Elitism
d_elite_solutions = d_solutions[:elite_size]
# Define a "parent pool" of the top 50% of solutions to breed from
d_parent_pool = d_solutions[:data_size // 2]
parent_pool_size = d_parent_pool.size
# 2. Crossover
# Select from the full list (indices 0 to data_size-1)
parent1_indices = cupy.random.randint(0, data_size, crossover_size)
parent2_indices = cupy.random.randint(0, data_size, crossover_size)
d_parents1 = d_solutions[parent1_indices]
d_parents2 = d_solutions[parent2_indices]
d_crossover_solutions = (d_parents1 + d_parents2) / 2.0
# 3. Mutation
# Select from the full list (indices 0 to data_size-1)
mutation_indices = cupy.random.randint(0, data_size, mutation_size)
d_mutation_candidates = d_solutions[mutation_indices]
# Use mutation_strength (the new name)
d_mutation_factors = cupy.random.uniform(
1 - options.mutation_strength,
1 + options.mutation_strength,
mutation_size
)
d_mutated_solutions = d_mutation_candidates * d_mutation_factors
# 4. New Randoms
d_random_solutions = cupy.random.uniform(
min_r, max_r, random_size, dtype=cupy.float64
)
# Assemble the new generation
d_solutions = cupy.concatenate([
d_elite_solutions,
d_crossover_solutions,
d_mutated_solutions,
d_random_solutions
])
# --- Final Step: Return the best results ---
# After all generations, do one last ranking to find the best solutions
fitness_gpu(
(blocks_per_grid,), (threads_per_block,),
(d_coefficients, d_coefficients.size, d_solutions, d_ranks, d_solutions.size, y_val)
)
# 1. Define quality based on the user's desired precision
# (e.g., precision=5 -> rank > 1e6, precision=8 -> rank > 1e9)
# We add +1 for a buffer, ensuring we only get high-quality roots.
quality_threshold = 10**(options.root_precision + 1)
# 2. Get all solutions that meet this quality threshold
d_high_quality_solutions = d_solutions[d_ranks > quality_threshold]
if d_high_quality_solutions.size == 0:
return np.array([])
# 3. Cluster these high-quality solutions on the GPU by rounding
d_rounded_solutions = cupy.round(d_high_quality_solutions, options.root_precision)
# 4. Get only the unique roots
d_unique_roots = cupy.unique(d_rounded_solutions)
# Sort the unique roots and copy back to CPU
final_solutions_gpu = cupy.sort(d_unique_roots)
return final_solutions_gpu.get()
def __str__(self) -> str:
"""Returns a human-readable string representation of the function."""
self._check_initialized()
parts = []
for i, c in enumerate(self.coefficients):
if c == 0:
continue
power = self._largest_exponent - i
# Coefficient part
coeff_val = c
if c == int(c):
coeff_val = int(c)
if coeff_val == 1 and power != 0:
coeff = ""
elif coeff_val == -1 and power != 0:
coeff = "-"
else:
coeff = str(coeff_val)
# Variable part
if power == 0:
var = ""
elif power == 1:
var = "x"
else:
var = f"x^{power}"
# Add sign for non-leading terms
sign = ""
if i > 0:
sign = " + " if c > 0 else " - "
coeff = str(abs(coeff_val))
if abs(c) == 1 and power != 0:
coeff = "" # Don't show 1 for non-constant terms
parts.append(f"{sign}{coeff}{var}")
# Join parts and clean up
result = "".join(parts)
if result.startswith(" + "):
result = result[3:]
return result if result else "0"
def __repr__(self) -> str:
return f"Function(str='{self}')"
def __add__(self, other: 'Function') -> 'Function':
"""Adds two Function objects."""
self._check_initialized()
other._check_initialized()
new_coefficients = np.polyadd(self.coefficients, other.coefficients)
result_func = Function(len(new_coefficients) - 1)
result_func.set_coeffs(new_coefficients.tolist())
return result_func
def __sub__(self, other: 'Function') -> 'Function':
"""Subtracts another Function object from this one."""
self._check_initialized()
other._check_initialized()
new_coefficients = np.polysub(self.coefficients, other.coefficients)
result_func = Function(len(new_coefficients) - 1)
result_func.set_coeffs(new_coefficients.tolist())
return result_func
def _multiply_by_scalar(self, scalar: Union[int, float]) -> 'Function':
"""Helper method to multiply the function by a scalar constant."""
self._check_initialized() # It's good practice to check here too
if scalar == 0:
result_func = Function(0)
result_func.set_coeffs([0])
return result_func
new_coefficients = self.coefficients * scalar
result_func = Function(self._largest_exponent)
result_func.set_coeffs(new_coefficients.tolist())
return result_func
def _multiply_by_function(self, other: 'Function') -> 'Function':
"""Helper method for polynomial multiplication (Function * Function)."""
self._check_initialized()
other._check_initialized()
# np.polymul performs convolution of coefficients to multiply polynomials
new_coefficients = np.polymul(self.coefficients, other.coefficients)
# The degree of the resulting polynomial is derived from the new coefficients
new_degree = len(new_coefficients) - 1
result_func = Function(new_degree)
result_func.set_coeffs(new_coefficients.tolist())
return result_func
def __mul__(self, other: Union['Function', int, float]) -> 'Function':
"""Multiplies the function by a scalar constant."""
if isinstance(other, (int, float)):
return self._multiply_by_scalar(other)
elif isinstance(other, self.__class__):
return self._multiply_by_function(other)
else:
return NotImplemented
def __rmul__(self, scalar: Union[int, float]) -> 'Function':
"""Handles scalar multiplication from the right (e.g., 3 * func)."""
return self.__mul__(scalar)
def __imul__(self, other: Union['Function', int, float]) -> 'Function':
"""Performs in-place multiplication by a scalar (func *= 3)."""
self._check_initialized()
if isinstance(other, (int, float)):
if other == 0:
self.coefficients = np.array([0], dtype=self.coefficients.dtype)
self._largest_exponent = 0
else:
self.coefficients *= other
elif isinstance(other, self.__class__):
other._check_initialized()
self.coefficients = np.polymul(self.coefficients, other.coefficients)
self._largest_exponent = len(self.coefficients) - 1
else:
return NotImplemented
return self
def quadratic_solve(self) -> Optional[List[float]]:
"""
Calculates the real roots of a quadratic function using the quadratic formula.
Args:
f (Function): A Function object of degree 2.
Returns:
Optional[List[float]]: A list containing the two real roots, or None if there are no real roots.
"""
self._check_initialized()
if self.largest_exponent != 2:
raise ValueError("Input function must be quadratic (degree 2) to use quadratic_solve.")
a, b, c = self.coefficients
discriminant = (b**2) - (4*a*c)
if discriminant < 0:
return None # No real roots
sqrt_discriminant = math.sqrt(discriminant)
root1 = (-b + sqrt_discriminant) / (2 * a)
root2 = (-b - sqrt_discriminant) / (2 * a)
return [root1, root2]
# Example Usage
if __name__ == '__main__':
print("--- Demonstrating Functionality ---")
# Create a quadratic function: 2x^2 - 3x - 5
f1 = Function(2)
f1.set_coeffs([2, -3, -5])
print(f"Function f1: {f1}")
# Solve for y
y = f1.solve_y(5)
print(f"Value of f1 at x=5 is: {y}") # Expected: 2*(25) - 3*(5) - 5 = 50 - 15 - 5 = 30
# Find the derivative: 4x - 3
df1 = f1.derivative()
print(f"Derivative of f1: {df1}")
# Find the second derivative: 4
ddf1 = f1.nth_derivative(2)
print(f"Second derivative of f1: {ddf1}")
# --- Root Finding ---
# 1. Analytical solution for quadratic
roots_analytic = quadratic_solve(f1)
print(f"Analytic roots of f1: {roots_analytic}") # Expected: -1, 2.5
# 2. Genetic algorithm solution
ga_opts = GA_Options(num_of_generations=20, data_size=50000)
print("\nFinding roots with Genetic Algorithm (CPU)...")
roots_ga_cpu = f1.get_real_roots(ga_opts)
print(f"Approximate roots from GA (CPU): {roots_ga_cpu}")
print("(Note: GA provides approximations around the true roots)")
# 3. CUDA accelerated genetic algorithm
if _CUPY_AVAILABLE:
print("\nFinding roots with Genetic Algorithm (CUDA)...")
# Since this PC has an RTX 4060 Ti, we can use the CUDA version.
roots_ga_gpu = f1.get_real_roots(ga_opts, use_cuda=True)
print(f"Approximate roots from GA (GPU): {roots_ga_gpu}")
else:
print("\nSkipping CUDA example: CuPy library not found or no compatible GPU.")
# --- Function Arithmetic ---
print("\n--- Function Arithmetic ---")
f2 = Function(1)
f2.set_coeffs([1, 10]) # x + 10
print(f"Function f2: {f2}")
# Addition: (2x^2 - 3x - 5) + (x + 10) = 2x^2 - 2x + 5
f_add = f1 + f2
print(f"f1 + f2 = {f_add}")
# Subtraction: (2x^2 - 3x - 5) - (x + 10) = 2x^2 - 4x - 15
f_sub = f1 - f2
print(f"f1 - f2 = {f_sub}")
# Multiplication: (x + 10) * 3 = 3x + 30
f_mul = f2 * 3
print(f"f2 * 3 = {f_mul}")
# f3 represents 2x^2 + 3x + 1
f3 = Function(2)
f3.set_coeffs([2, 3, 1])
print(f"Function f3: {f3}")
# f4 represents 5x - 4
f4 = Function(1)
f4.set_coeffs([5, -4])
print(f"Function f4: {f4}")
# Multiply the two functions
product_func = f3 * f4
print(f"f3 * f4 = {product_func}")