1+
2+ """
3+ Domain and Set classes for fuzzy logic.
4+
5+ Primary abstractions for recursive functions for better handling.
6+ """
7+
8+ import matplotlib .pyplot as plt
9+ from numpy import arange , fromiter , array_equal , less_equal , greater_equal , less , greater
10+ import numpy as np
11+ from logging import warn
12+ import pickle
13+
14+ from .functions import inv , normalize
15+ from .combinators import MAX , MIN , product , bounded_sum , simple_disjoint_sum
16+
17+ class FuzzyWarning (UserWarning ):
18+ """Extra Exception so that user code can filter exceptions specific to this lib."""
19+ pass
20+
21+
22+ class Domain :
23+ """
24+ A domain is a 'measurable' dimension of 'real' values like temperature.
25+
26+ There must be a lower and upper limit and a resolution (the size of steps)
27+ specified.
28+
29+ Fuzzysets are defined within one such domain and are only meaningful
30+ while considered within their domain ('apples and bananas').
31+ To operate with sets across domains, there needs to be a mapping.
32+
33+ The sets are accessed as attributes of the domain like
34+ >>> temp = Domain('temperature', 0, 100)
35+ >>> temp.hot = Set(lambda x: 0)
36+ >>> temp.hot(5)
37+ 0
38+
39+ DO NOT call a derived set without assignment first as it WILL
40+ confuse the recursion and seriously mess up.
41+ NOT: ~temp.hot(2) or ~(temp.hot.)(2)
42+ BUT:
43+ >>> not_hot = ~temp.hot
44+ >>> not_hot(2)
45+ 1
46+
47+ You MUST NOT add arbitrary attributes to an *instance* of Domain - you can
48+ however subclass or modify the class itself. If you REALLY have to add attributes,
49+ make sure to "whitelist" it in __slots__ first.
50+
51+ Use the Domain by calling it with the value in question. This returns a
52+ dictionary with the degrees of membership per set. You MAY override __call__
53+ in a subclass to enable concurrent evaluation for performance improvement.
54+ >>> temp.cold = not_hot
55+ >>> temp(3) == {"hot": 0, "cold": 1}
56+ True
57+ """
58+
59+ __slots__ = ['_name' , '_low' , '_high' , '_res' , '_sets' ]
60+
61+ def __init__ (self , name , low , high , * , res = 1 , sets :dict = None ):
62+ """Define a domain."""
63+ assert low < high , "higher bound must be greater than lower."
64+ assert res > 0 , "resolution can't be negative or zero"
65+ self ._name = name
66+ self ._high = high
67+ self ._low = low
68+ self ._res = res
69+ self ._sets = {} if sets is None else sets # Name: Set(Function())
70+
71+
72+ def __call__ (self , x ):
73+ """Pass a value to all sets of the domain and return a dict with results."""
74+ if not (self ._low <= x <= self ._high ):
75+ warn (f"{ x } is outside of domain!" )
76+ memberships = {name : s .func (x ) for name , s in self ._sets .items ()}
77+ return memberships
78+
79+ def __str__ (self ):
80+ """Return a string to print()."""
81+ return self ._name
82+
83+ def __repr__ (self ):
84+ """Return a string so that eval(repr(Domain)) == Domain."""
85+ return f"Domain('{ self ._name } ', { self ._low } , { self ._high } , res={ self ._res } , sets={ self ._sets } )"
86+
87+ def __eq__ (self , other ):
88+ """Test equality of two domains."""
89+ return all ([self ._name == other ._name ,
90+ self ._low == other ._low ,
91+ self ._high == other ._high ,
92+ self ._res == other ._res ,
93+ self ._sets == other ._sets ])
94+
95+ def __getattr__ (self , name ):
96+ """Get the value of an attribute. Is called after __getattribute__ is called with an AttributeError."""
97+ if name in self ._sets :
98+ return self ._sets [name ]
99+ else :
100+ raise AttributeError (f"{ name } is not a set or attribute" )
101+
102+ def __setattr__ (self , name , value ):
103+ """Define a set within a domain or assign a value to a domain attribute."""
104+ # It's a domain attr
105+ if name in self .__slots__ :
106+ object .__setattr__ (self , name , value )
107+ # We've got a fuzzyset
108+ else :
109+ assert str .isidentifier (name ), f"{ name } must be an identifier."
110+ if not isinstance (value , Set ):
111+ # Often useful to just assign a function for simple sets..
112+ value = Set (value )
113+ # However, we need the abstraction if we want to use Superfuzzysets (derived sets).
114+ self ._sets [name ] = value
115+ value .domain = self
116+ value .name = name
117+
118+ def __delattr__ (self , name ):
119+ """Delete a fuzzy set from the domain."""
120+ if name in self ._sets :
121+ del self ._sets [name ]
122+ else :
123+ raise FuzzyWarning ("Trying to delete a regular attr, this needs extra care." )
124+
125+ def range (self ):
126+ """Return an arange object with the domain's specifics.
127+
128+ This is used to conveniently iterate over all possible values
129+ for plotting etc.
130+
131+ High upper bound is INCLUDED unlike range.
132+ """
133+ return arange (self ._low , self ._high + self ._res , self ._res )
134+
135+ def min (self , x ):
136+ """Standard way to get the min over all membership funcs.
137+
138+ It's not just more convenient but also faster than
139+ to calculate all results, construct a dict, unpack the dict
140+ and calculate the min from that.
141+ """
142+ return min (f (x ) for f in self ._sets .values ())
143+
144+ def max (self , x ):
145+ """Standard way to get the max over all membership funcs."""
146+ return max (f (x ) for f in self ._sets .values ())
147+
148+ class Set :
149+ """
150+ A fuzzyset defines a 'region' within a domain.
151+
152+ The associated membership function defines 'how much' a given value is
153+ inside this region - how 'true' the value is.
154+
155+ Sets and functions MUST NOT be mixed because functions don't have
156+ the methods of the sets needed for the logic.
157+
158+ Sets that are returned from one of the operations are 'derived sets' or
159+ 'Superfuzzysets' according to Zadeh.
160+
161+ Note that most checks are merely assertions that can be optimized away.
162+ DO NOT RELY on these checks and use tests to make sure that only valid calls are made.
163+
164+ This class uses the classical MIN/MAX operators for AND/OR. To use different operators, simply subclass and
165+ replace the __and__ and __or__ functions. However, be careful not to mix the classes logically,
166+ since it might be confusing which operator will be used (left/right binding).
167+
168+ """
169+ name = None # these are set on assignment to the domain! DO NOT MODIFY
170+ domain = None
171+
172+
173+ def __init__ (self , func :callable , * , name = None , domain = None ):
174+ """Initialize the set."""
175+ assert callable (func ) or isinstance (func , str )
176+ # if func is a str, we've got a pickled function via repr
177+ if isinstance (func , str ):
178+ try :
179+ func = pickle .loads (func )
180+ except :
181+ FuzzyWarning ("Can't load pickled function %s" % func )
182+ self .func = func
183+
184+ if name is not None and domain is not None :
185+ setattr (domain , name , self )
186+
187+ if bool (name )^ bool (domain ):
188+ raise FuzzyWarning ("Name or domain is provided, but not both!" )
189+
190+ def __call__ (self , x ):
191+ """Call the function of the set (which can be of any arbitrary complexity)."""
192+ return self .func (x )
193+
194+ def __invert__ (self ):
195+ """Return a new set with modified function."""
196+ return Set (inv (self .func ))
197+
198+ def __neg__ (self ):
199+ return Set (inv (self .func ))
200+
201+ def __and__ (self , other ):
202+ """Return a new set with modified function."""
203+ return Set (MIN (self .func , other .func ))
204+
205+ def __or__ (self , other ):
206+ """Return a new set with modified function."""
207+ return Set (MAX (self .func , other .func ))
208+
209+ def __mul__ (self , other ):
210+ """Return a new set with modified function."""
211+ return Set (product (self .func , other .func ))
212+
213+ def __add__ (self , other ):
214+ """Return a new set with modified function."""
215+ return Set (bounded_sum (self .func , other .func ))
216+
217+ def __xor__ (self , other ):
218+ """Return a new set with modified function."""
219+ return Set (simple_disjoint_sum (self .func , other .func ))
220+
221+ def __pow__ (self , power ):
222+ """Return a new set with modified function."""
223+ #FYI: pow is used with hedges
224+ return Set (lambda x : pow (self .func (x ), power ))
225+
226+ def __eq__ (self , other ):
227+ """A set is equal with another if both return the same values over the same range."""
228+ if self .domain is None or other .domain is None :
229+ # It would require complete AST analysis to check whether both Sets
230+ # represent the same recursive functions -
231+ # additionally, there are infinitely many mathematically equivalent
232+ # functions that don't have the same bytecode...
233+ raise FuzzyWarning ("Impossible to determine." )
234+ else :
235+ # however, if domains ARE assigned (whether or not it's the same domain),
236+ # we simply can check if they map to the same values
237+ return array_equal (self .array (), other .array ())
238+
239+ def __le__ (self , other ):
240+ """If this <= other, it means this is a subset of the other."""
241+ if self .domain is None or other .domain is None :
242+ raise FuzzyWarning ("Can't compare without Domains." )
243+ return all (less_equal (self .array (), other .array ()))
244+
245+ def __lt__ (self , other ):
246+ """If this < other, it means this is a proper subset of the other."""
247+ if self .domain is None or other .domain is None :
248+ raise FuzzyWarning ("Can't compare without Domains." )
249+ return all (less (self .array (), other .array ()))
250+
251+ def __ge__ (self , other ):
252+ """If this >= other, it means this is a superset of the other."""
253+ if self .domain is None or other .domain is None :
254+ raise FuzzyWarning ("Can't compare without Domains." )
255+ return all (greater_equal (self .array (), other .array ()))
256+
257+ def __gt__ (self , other ):
258+ """If this > other, it means this is a proper superset of the other."""
259+ if self .domain is None or other .domain is None :
260+ raise FuzzyWarning ("Can't compare without Domains." )
261+ return all (greater (self .array (), other .array ()))
262+
263+ def __len__ (self ):
264+ """Number of membership values in the set, defined by bounds and resolution of domain."""
265+ if self .domain is None :
266+ raise FuzzyWarning ("No domain." )
267+ return len (self .array ())
268+
269+ def cardinality (self ):
270+ """The sum of all values in the set."""
271+ if self .domain is None :
272+ raise FuzzyWarning ("No domain." )
273+ return sum (self .array ())
274+
275+ def relative_cardinality (self ):
276+ """Relative cardinality is the sum of all membership values by number of all values."""
277+ if self .domain is None :
278+ raise FuzzyWarning ("No domain." )
279+ if len (self ) == 0 :
280+ # this is highly unlikely and only possible with res=inf but still..
281+ raise FuzzyWarning ("The domain has no element." )
282+ return self .cardinality () / len (self )
283+
284+ def concentrated (self ):
285+ """
286+ Alternative to hedge "very".
287+
288+ Returns an new set that has a reduced amount of values the set includes and to dampen the
289+ membership of many values.
290+ """
291+ return Set (lambda x : self .func (x ) ** 2 )
292+
293+ def intensified (self ):
294+ """
295+ Alternative to using hedges.
296+
297+ Returns a new set where the membership of values are increased that
298+ already strongly belong to the set and dampened the rest.
299+ """
300+ def f (x ):
301+ if x < 0.5 :
302+ return 2 * self .func (x )** 2
303+ else :
304+ return 1 - 2 (1 - self .func (x )** 2 )
305+ return Set (f )
306+
307+ def dilated (self ):
308+ """Expand the set with more values and already included values are enhanced.
309+ """
310+ return Set (lambda x : self .func (x ) ** 1. / 2. )
311+
312+ def multiplied (self , n ):
313+ """Multiply with a constant factor, changing all membership values."""
314+ return Set (lambda x : self .func (x ) * n )
315+
316+ def plot (self ):
317+ """Graph the set in the given domain."""
318+ if self .domain is None :
319+ raise FuzzyWarning ("No domain assigned, cannot plot." )
320+ R = self .domain .range ()
321+ V = [self .func (x ) for x in R ]
322+ plt .plot (R , V )
323+
324+ def array (self ):
325+ """Return an array of all values for this set within the given domain."""
326+ if self .domain is None :
327+ raise FuzzyWarning ("No domain assigned." )
328+ return fromiter ((self .func (x ) for x in self .domain .range ()), float )
329+
330+ def __repr__ (self ):
331+ """
332+ Return a string representation of the Set that reconstructs the set with eval().
333+
334+ *******
335+ Current implementation does NOT work correctly.
336+
337+ This is harder than expected since all functions are (recursive!) closures which
338+ can't simply be pickled. If this functionality really is needed, all functions
339+ would have to be peppered with closure-returning overhead such as
340+
341+ def create_closure_and_function(*args):
342+ func = None
343+ def create_function_closure():
344+ return func
345+
346+ closure = create_function_closure.__closure__
347+ func = types.FunctionType(*args[:-1] + [closure])
348+ return func
349+ """
350+ return f"Set({ self .func } )"
351+
352+ def __str__ (self ):
353+ """Return a string for print()."""
354+ if self .name is None and self .domain is None :
355+ return f"dangling Set({ self .func } )"
356+ else :
357+ return f"{ self .domain ._name } .{ self .name } "
358+
359+ def normalized (self ):
360+ """Return a set that is normalized *for this domain* with 1 as max."""
361+ if self .domain is None :
362+ raise FuzzyWarning ("Can't normalize without domain." )
363+ return Set (normalize (max (self .array ()), self .func ))
364+
365+ if __name__ == "__main__" :
366+ import doctest
367+ doctest .testmod ()
0 commit comments