Skip to content

Commit 7191fba

Browse files
committed
feature: preliminary function & parameter-guessing
1 parent 858b497 commit 7191fba

File tree

2 files changed

+223
-0
lines changed

2 files changed

+223
-0
lines changed

src/fuzzylogic/estimate.py

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
"""Guesstimate the membership functions and their parameters of a fuzzy logic system.
2+
3+
How this works:
4+
1. We normalize the target array to a very small size, in the range [0, 1].
5+
2. We guess which functions match well based on the normalized array,
6+
only caring about the shape of the function, not the actual values.
7+
3. We take the best matching functions and start guessing the parameters applying evolutionary algorithms.
8+
4. Using the best matching functions with their parameters, we get some preliminary results.
9+
5. We use the preliminary results to construct an array of the same size as the input array,
10+
but with the membership function applied. The difference of the two arrays is the new target.
11+
6. Start the process again with the new target. Repeat until there is no difference between the two arrays.
12+
7. The final result is the combination of those functions with their parameters.
13+
"""
14+
15+
import contextlib
16+
import inspect
17+
import sys
18+
from itertools import permutations
19+
from random import choice, randint
20+
from statistics import median
21+
from typing import Callable
22+
23+
import numpy as np
24+
25+
from .functions import R, S, constant, gauss, rectangular, sigmoid, singleton, step, trapezoid, triangular
26+
27+
np.seterr(all="raise")
28+
functions = [step, rectangular]
29+
30+
argument1_functions = [singleton, constant]
31+
argument2_functions = [R, S, gauss]
32+
argument3_functions = [triangular, sigmoid]
33+
argument4_functions = [trapezoid]
34+
35+
36+
def normalize(target: np.ndarray, output_length: int = 16) -> np.ndarray:
37+
"""Normalize and interpolate a numpy array.
38+
39+
Return an array of output_length and normalized values.
40+
"""
41+
min_val = np.min(target)
42+
max_val = np.max(target)
43+
if min_val == max_val:
44+
return np.ones(output_length)
45+
normalized_array = (target - min_val) / (max_val - min_val)
46+
normalized_array = np.interp(
47+
np.linspace(0, 1, output_length), np.linspace(0, 1, len(normalized_array)), normalized_array
48+
)
49+
return normalized_array
50+
51+
52+
def guess_function(target: np.ndarray) -> Callable:
53+
normalized = normalize(target)
54+
# trivial case
55+
return constant if np.all(normalized == 1) else singleton
56+
57+
58+
def fitness(func: Callable, target: np.ndarray, certainty: int | None = None) -> float:
59+
"""Compute the difference between the array and the function evaluated at the parameters.
60+
61+
if the error is 0, we have a perfect match: fitness -> 1
62+
if the error approaches infinity, we have a bad match: fitness -> 0
63+
"""
64+
test = np.fromiter([func(x) for x in np.arange(*target.shape)], float)
65+
result = 1 / (np.sum(np.abs((test - target))) + 1)
66+
return result if certainty is None else round(result, certainty)
67+
68+
69+
def seed_population(func: Callable, target: np.ndarray) -> dict[tuple, float]:
70+
# create a random population of parameters
71+
params = [p for p in inspect.signature(func).parameters.values() if p.kind == p.POSITIONAL_OR_KEYWORD]
72+
seed_population = {}
73+
seed_numbers = [
74+
sys.float_info.min,
75+
sys.float_info.max,
76+
0,
77+
1,
78+
-1,
79+
0.5,
80+
-0.5,
81+
min(target),
82+
max(target),
83+
np.argmax(target),
84+
]
85+
# seed population
86+
for combination in permutations(seed_numbers, len(params)):
87+
with contextlib.suppress(Exception):
88+
seed_population[combination] = fitness(func(*combination), target)
89+
assert seed_population, "Failed to seed population - wtf?"
90+
return seed_population
91+
92+
93+
def reproduce(parent1: tuple, parent2: tuple) -> tuple:
94+
child = []
95+
for p1, p2 in zip(parent1, parent2):
96+
# mix the parts of the floats by randomness within the range of the parents
97+
# adding a random jitter should avoid issues when p1 == p2
98+
a1, a2 = np.frexp(p1)
99+
b1, b2 = np.frexp(p2)
100+
a1 += randint(-1, 1)
101+
a2 += randint(-1, 1)
102+
b1 += randint(-1, 1)
103+
b2 += randint(-1, 1)
104+
child.append(((a1 + b1) / 2) * 2 ** np.random.uniform(a2, b2))
105+
return tuple(child)
106+
107+
108+
def guess_parameters(
109+
func: Callable, target: np.ndarray, precision: int | None = None, certainty: int | None = None
110+
) -> tuple:
111+
"""Find the best fitting parameters for a function, targetting an array.
112+
113+
Args:
114+
func (Callable): A possibly matching membership function, such as `fuzzylogic.functions.triangular`.
115+
array (np.ndarray): The target array to fit the function to.
116+
117+
Returns:
118+
tuple: The best fitting parameters for the function.
119+
"""
120+
121+
def best() -> tuple:
122+
return sorted(population.items(), key=lambda x: x[1])[0][0]
123+
124+
seed_pop = seed_population(func, target)
125+
population = seed_pop.copy()
126+
print(seed_pop)
127+
# iterate until convergence or max iterations
128+
pressure = 0
129+
pop_size = 100
130+
last_pop = {}
131+
for generation in range(12):
132+
# sort the population by fitness
133+
pop: list[tuple[tuple, float]] = sorted(population.items(), key=lambda x: x[1], reverse=True)[
134+
:pop_size
135+
]
136+
if not pop:
137+
population = last_pop
138+
return best()
139+
print(f"Best so far:: {func.__name__}(*{pop[0][0]}) with {pop[0][1]:.10f}")
140+
# maybe the seed population already has a perfect match?
141+
if pop[0][1] == 1:
142+
print("Lucky!")
143+
return best()
144+
# the next generation
145+
new_population = {}
146+
killed = 0
147+
for parent1 in pop:
148+
while True:
149+
with contextlib.suppress(Exception):
150+
# select another parent and try to reproduce - try until it works once
151+
# at least one viable child is guaranteed (parent1 == parent2)
152+
parent2 = choice(pop)
153+
child = reproduce(parent1[0], parent2[0])
154+
new_population[child] = (fit := fitness(func(*child), target))
155+
# check for convergence
156+
if fit == 1:
157+
print("Lucky!")
158+
return child
159+
# kill the worst
160+
if fit <= pressure:
161+
del new_population[child]
162+
killed += 1
163+
if killed % 1000 == 0:
164+
print("xxx")
165+
if killed > 10000:
166+
break
167+
else:
168+
if len(new_population) % 1000 == 0:
169+
print("...")
170+
break
171+
print(
172+
f"Generation {generation}: {killed} killed; pop size {len(population)}; pressure {pressure:.10f}"
173+
)
174+
if last_pop == new_population:
175+
break
176+
last_pop = population
177+
population = new_population
178+
# Under Pressure!
179+
if len(population) == 1:
180+
print("Only a single survivor!")
181+
break
182+
if killed > 1000:
183+
pop_size += 1000
184+
pressure **= 0.999
185+
population |= seed_pop
186+
else:
187+
pressure = median([x[1] for x in population.items()])
188+
return best()
189+
190+
191+
def shave(target: np.ndarray, components: dict[Callable, tuple]) -> np.ndarray:
192+
"""Remove the membership functions from the target array."""
193+
result = np.zeros_like(target)
194+
for func, params in components.items():
195+
f = func(*params)
196+
result += np.fromiter([f(x) for x in np.arange(*target.shape)], float)
197+
return target - result

src/fuzzylogic/neural_network.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from collections import defaultdict
2+
3+
import numpy as np
4+
5+
from .functions import R, S, constant, gauss, rectangular, sigmoid, singleton, step, trapezoid, triangular
6+
7+
functions = [step, rectangular]
8+
9+
argument1_functions = [singleton, constant]
10+
argument2_functions = [R, S, gauss]
11+
argument3_functions = [triangular, sigmoid]
12+
argument4_functions = [trapezoid]
13+
14+
15+
def generate_examples() -> dict[str, list[np.ndarray]]:
16+
examples = defaultdict(lambda: [])
17+
examples["constant"] = [np.ones(16)]
18+
for x in range(16):
19+
A = np.zeros(16)
20+
A[x] = 1
21+
examples["singleton"].append(A)
22+
23+
for x in range(1, 16):
24+
func = R(0, x)
25+
examples["R"].append(func(np.linspace(0, 1, 16)))
26+
return examples

0 commit comments

Comments
 (0)