wolfhece 2.1.124__py3-none-any.whl → 2.1.125__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,776 @@
1
+
2
+ import numpy as np
3
+ from numba import njit, jit
4
+ from scipy.optimize import minimize
5
+ import logging
6
+
7
+ """ Using JIT to speed up the functions """
8
+ @njit
9
+ def sigmoid(x:np.float64, loc:np.float64, scale:np.float64) -> float:
10
+ """ Sigmoid function """
11
+ return 1. / (1. + np.exp(-scale * (x - loc)))
12
+
13
+ @njit
14
+ def sigmoid_derivative(x:np.float64, loc:np.float64, scale:np.float64) -> float:
15
+ """ Derivative of the sigmoid function """
16
+ s = sigmoid(x, loc, scale)
17
+ return scale * s * (1. - s)
18
+
19
+ @njit
20
+ def sigmoid_second_derivative(x:np.float64, loc:np.float64, scale:np.float64) -> float:
21
+ """ Second derivative of the sigmoid function """
22
+ s = sigmoid(x, loc, scale)
23
+ return scale**2. * s * (1. - s) * (1. - 2. * s)
24
+
25
+ @njit
26
+ def one_minus_sigmoid(x:np.float64, loc:np.float64, scale:np.float64) -> float:
27
+ """ 1 - Sigmoid function """
28
+ return 1. - sigmoid(x, loc, scale)
29
+
30
+ @njit
31
+ def extract_xy_binary_search(x_candidate:np.ndarray, x:np.ndarray, y:np.ndarray, nb_elemt:np.int64) -> tuple[np.ndarray, np.ndarray]:
32
+ """ Binary search to find the interval of x values for each x_candidate
33
+
34
+ :param x_candidate: np.ndarray values to extract the x and y values
35
+ :param x: np.ndarray, list of floats, x values of the points
36
+ :param y: np.ndarray, list of floats, y values of the points
37
+ :param nb_elemt: int, number of elements to consider around the x_candidate value (must be odd)
38
+ """
39
+
40
+ n = len(x) - 1
41
+
42
+ if np.mod(nb_elemt, 2) == 0:
43
+ nb_elemt += 1
44
+
45
+ nb_elemt = min(n, nb_elemt)
46
+
47
+ results = np.zeros_like(x_candidate, dtype=np.int64)
48
+
49
+ for idx, cur_x in enumerate(x_candidate):
50
+ if cur_x < x[0]:
51
+ results[idx] = 0
52
+ elif cur_x > x[-1]:
53
+ results[idx] = n - 1
54
+ else:
55
+ left = 0
56
+ right = n - 1
57
+
58
+ while right - left > 1:
59
+ mid = (left + right) // 2
60
+ if cur_x < x[mid]:
61
+ right = mid
62
+ else:
63
+ left = mid
64
+
65
+ results[idx] = left
66
+
67
+ new_x = np.zeros((len(x_candidate), nb_elemt), dtype=np.float64)
68
+ new_y = np.zeros((len(x_candidate), nb_elemt), dtype=np.float64)
69
+
70
+ for idx, cur_x in enumerate(x_candidate):
71
+ i = results[idx]
72
+ if i < nb_elemt//2:
73
+ new_x[idx,:] = x[:nb_elemt]
74
+ new_y[idx,:] = y[:nb_elemt]
75
+ elif i > n - nb_elemt//2:
76
+ new_x[idx,:] = x[-nb_elemt:]
77
+ new_y[idx,:] = y[-nb_elemt:]
78
+ else:
79
+ new_x[idx,:] = x[i-nb_elemt//2 : i+nb_elemt//2+1]
80
+ new_y[idx,:] = y[i-nb_elemt//2 : i+nb_elemt//2+1]
81
+
82
+ return new_x, new_y
83
+
84
+ @njit
85
+ def _piecewise_linear(x_candidate:np.ndarray, x:np.ndarray, y:np.ndarray,
86
+ scale:np.float64, slope_left:np.float64, slope_right:np.float64) -> np.ndarray:
87
+ """ Piecewise linear function with continuous transition by sigmoids.
88
+
89
+ In extrapolation mode, the function is y[0] for x < x[0] and linearly extrapoletd based on the last slope for x > x[-1].
90
+
91
+ :param x_candidate: np.ndarray, list or float, x values to evaluate the function
92
+ :param x: np.ndarray, list of floats, x values of the points
93
+ :param y: np.ndarray, list of floats, y values of the points
94
+ :param scale: float, scale of the sigmoid functions
95
+ :param slope_left: float, slope before the first segment - extrapolation mode
96
+ :param slope_right: float, slope after the last segment - extrapolation mode
97
+ """
98
+
99
+ # FIXME : Numba.JIT does not like np.concatenate, np.hstack... so we pre-allocate the arrays
100
+
101
+ # Extend the x and y values to allow extrapolation based on slope_left and slope_right
102
+ xx = np.zeros(x.shape[0]+1) # add the extrapolation points
103
+ yy = np.zeros(y.shape[0]+1) # add the extrapolation points
104
+ slopes = np.zeros(x.shape[0]+1)
105
+
106
+ xx[0] = x[0]
107
+ yy[0] = y[0]
108
+ xx[1:] = x
109
+ yy[1:] = y
110
+
111
+ x = xx
112
+ y = yy
113
+
114
+ n = len(x) # number of intervals/segments taking into account the extrapolation
115
+ results = np.zeros_like(x_candidate) # pre-allocate the results, force numpy type and not a list
116
+
117
+ functions = np.zeros(n) # values of the linear functions for each segment
118
+
119
+ sigmoids = np.ones(n)
120
+ one_minus_sigmoids = np.ones(n)
121
+
122
+ # local slopes of the segments -- must be a **function** (no vertical slope, x increasing...)
123
+ slopes[0] = slope_left
124
+ slopes[1:-1] = (y[2:] - y[1:-1]) / (x[2:] - x[1:-1])
125
+ slopes[-1] = slope_right
126
+
127
+ for idx, cur_x in enumerate(x_candidate):
128
+ # Copy the x value for each segment
129
+ xvals = np.full(n, cur_x)
130
+ # Compute the value of the linear function for each segment
131
+ functions[:] = slopes[:] * (xvals[:] - x[:]) + y[:]
132
+ # Compute the value of the sigmoid function for each segment (based on the start of the segment)
133
+ sigmoids[1:] = sigmoid(xvals[1:], x[1:], scale)
134
+ # Compute the value of 1 - sigmoid for each segment (based on the end of the segment)
135
+ one_minus_sigmoids[:-1] = 1. - sigmoids[1:]
136
+
137
+ """
138
+ Interpolation mode : x[0] <= x_candidate <= x[-1]
139
+ ------------------
140
+
141
+ We will combine the results of each segment.
142
+ For each segment, we use a door function that is 1 if we are in the segment and 0 otherwise.
143
+
144
+ 1 ____________
145
+ | |
146
+ | |
147
+ | |
148
+ 0______| |_______
149
+ x1 x2
150
+
151
+ The door function is the product of the sigmoid of the segment and the (1 - sigmoid) of the next segment.
152
+
153
+ Door_i = sigmoid_i * (1 - sigmoid_{i+1})
154
+
155
+ So, for x, we can compute the value of the function as a sum of the value of the function for each segment multiplied by the door function.
156
+
157
+ f(x) = sum_i (functions_i(x) * Door_i)
158
+ """
159
+
160
+ results[idx] = np.sum(sigmoids * one_minus_sigmoids * functions)
161
+
162
+ return results
163
+
164
+ @njit
165
+ def piecewise_linear(x_candidate:np.ndarray, x:np.ndarray, y:np.ndarray,
166
+ scale:np.float64, nb_elemt:np.int64,
167
+ slope_left:np.float64, slope_right:np.float64) -> np.ndarray:
168
+ """ Piecewise linear function with continuous transition by sigmoids
169
+
170
+ :param x_candidate: np.ndarray, list or float, x values to evaluate the function
171
+ :param x: np.ndarray, list of floats, x values of the points
172
+ :param y: np.ndarray, list of floats, y values of the points
173
+ :param scale: float, scale of the sigmoid functions
174
+ :param nb_elemt: int, number of elements to consider around the x_candidate value
175
+ """
176
+
177
+ results = np.zeros_like(x_candidate)
178
+
179
+ if nb_elemt == -1:
180
+
181
+ results[:] = _piecewise_linear(x_candidate, x, y, scale, slope_left, slope_right)
182
+
183
+ else:
184
+ xx, yy = extract_xy_binary_search(x_candidate, x, y, nb_elemt)
185
+
186
+ for idx, cur_x in enumerate(x_candidate):
187
+ results[idx] = _piecewise_linear(np.array([cur_x]), xx[idx], yy[idx], scale, slope_left, slope_right)[0]
188
+
189
+ return results
190
+
191
+ @njit
192
+ def _gradient_piecewise_linear(x_candidate:np.ndarray, x:np.ndarray, y:np.ndarray,
193
+ scale:np.float64, slope_left:np.float64, slope_right:np.float64) -> np.ndarray:
194
+ """ Gradient of the piecewise linear function """
195
+
196
+ xx = np.zeros(x.shape[0]+1) # add the extrapolation points
197
+ yy = np.zeros(y.shape[0]+1) # add the extrapolation points
198
+ slopes = np.zeros(x.shape[0]+1)
199
+
200
+ xx[0] = x[0]
201
+ yy[0] = y[0]
202
+ xx[1:] = x
203
+ yy[1:] = y
204
+
205
+ x = xx
206
+ y = yy
207
+
208
+ n = len(x)
209
+ results = np.zeros_like(x_candidate)
210
+
211
+ functions = np.zeros(n) # values of the linear functions for each segment
212
+
213
+ sigmoids = np.ones(n)
214
+ one_minus_sigmoids = np.ones(n)
215
+
216
+ slopes[0] = slope_left
217
+ slopes[1:-1] = (y[2:] - y[1:-1]) / (x[2:] - x[1:-1])
218
+ slopes[-1] = slope_right
219
+
220
+ for idx, cur_x in enumerate(x_candidate):
221
+ # Copy the x value for each segment
222
+ xvals = np.full(n, cur_x)
223
+ # Compute the value of the linear function for each segment
224
+ functions[:] = slopes[:] * (xvals[:] - x[:]) + y[:]
225
+ # Compute the value of the sigmoid function for each segment (based on the start of the segment)
226
+ sigmoids[1:] = sigmoid(xvals[1:], x[1:], scale)
227
+ # Compute the value of 1 - sigmoid for each segment (based on the end of the segment)
228
+ one_minus_sigmoids[:-1] = 1. - sigmoids[1:]
229
+
230
+ def derivative_sigmoid(sig, scale):
231
+ return scale * sig * (1. - sig)
232
+
233
+ def derivative_oneminussigmoid(oneminussig, scale):
234
+ return -derivative_sigmoid(oneminussig, scale)
235
+
236
+ result = 0.0
237
+ for i in range(n):
238
+ result += sigmoids[i] * one_minus_sigmoids[i] * slopes[i]
239
+ result += (derivative_sigmoid(sigmoids[i], scale) * one_minus_sigmoids[i] + sigmoids[i] * derivative_oneminussigmoid(one_minus_sigmoids[i], scale)) * functions[i]
240
+
241
+ results[idx] = result
242
+
243
+ return results
244
+
245
+ @njit
246
+ def gradient_piecewise_linear(x_candidate:np.ndarray, x:np.ndarray, y:np.ndarray,
247
+ scale:np.float64, nb_elemt:np.int64,
248
+ slope_left:np.float64, slope_right:np.float64) -> np.ndarray:
249
+ """ Gradient of the piecewise linear function """
250
+
251
+ results = np.zeros_like(x_candidate)
252
+
253
+ if nb_elemt == -1:
254
+
255
+ results[:] = _gradient_piecewise_linear(x_candidate, x, y, scale, slope_left, slope_right)
256
+
257
+ else:
258
+ xx, yy = extract_xy_binary_search(x_candidate, x, y, nb_elemt)
259
+
260
+ for idx, cur_x in enumerate(x_candidate):
261
+ results[idx] = _gradient_piecewise_linear(np.array([cur_x]), xx[idx], yy[idx], scale, slope_left, slope_right)[0]
262
+
263
+ return results
264
+
265
+ @njit
266
+ def _gradient_piecewise_linear_approx(x_candidate:np.ndarray, x:np.ndarray, y:np.ndarray,
267
+ scale:np.float64, slope_left:np.float64, slope_right:np.float64):
268
+ """ Approximative gradient of the piecewise linear function.
269
+
270
+ The derivative contribution of the sigmoids is ignored.
271
+ """
272
+
273
+ x = np.hstack((x[0], x)) # add the extrapolation points
274
+ y = np.hstack((y[0], y)) # add the extrapolation points
275
+
276
+ n = len(x)
277
+ results = np.zeros_like(x_candidate)
278
+
279
+ functions = np.zeros(n) # values of the linear functions for each segment
280
+
281
+ sigmoids = np.ones(n)
282
+ one_minus_sigmoids = np.ones(n)
283
+
284
+ slopes = np.concatenate([ [slope_left], (y[2:] - y[1:-1]) / (x[2:] - x[1:-1]), [slope_right]])
285
+
286
+ for idx, cur_x in enumerate(x_candidate):
287
+ # Copy the x value for each segment
288
+ xvals = np.full(n, cur_x)
289
+ # Compute the value of the linear function for each segment
290
+ functions[:] = slopes[:] * (xvals[:] - x[:]) + y[:]
291
+ # Compute the value of the sigmoid function for each segment (based on the start of the segment)
292
+ sigmoids[1:] = sigmoid(xvals[1:], x[1:], scale)
293
+ # Compute the value of 1 - sigmoid for each segment (based on the end of the segment)
294
+ one_minus_sigmoids[:-1] = 1. - sigmoids[1:]
295
+
296
+ results[idx] = np.sum(sigmoids * one_minus_sigmoids * slopes)
297
+ # result = 0.0
298
+ # for i in range(n):
299
+ # result += sigmoids[i] * one_minus_sigmoids[i] * slopes[i]
300
+
301
+ # results[idx] = result
302
+
303
+ return results
304
+
305
+ @njit
306
+ def gradient_piecewise_linear_approx(x_candidate:np.ndarray, x:np.ndarray, y:np.ndarray,
307
+ scale:np.float64, nb_elemt:np.int64,
308
+ slope_left:np.float64, slope_right:np.float64):
309
+ """ Approximative gradient of the piecewise linear function.
310
+
311
+ The derivative contribution of the sigmoids is ignored.
312
+ """
313
+
314
+ results = np.zeros_like(x_candidate)
315
+
316
+ if nb_elemt == -1:
317
+
318
+ results[:] = _gradient_piecewise_linear_approx(x_candidate, x, y, scale, slope_left, slope_right)
319
+
320
+ else:
321
+ xx, yy = extract_xy_binary_search(x_candidate, x, y, nb_elemt)
322
+
323
+ for idx, cur_x in enumerate(x_candidate):
324
+ results[idx] = _gradient_piecewise_linear_approx(np.array([cur_x]), xx[idx], yy[idx], scale, slope_left, slope_right)[0]
325
+
326
+ return results
327
+
328
+ class Piecewise_Linear_Sigmoid():
329
+ """ Piecewise linear function with smooth transitions using sigmoids """
330
+
331
+ def __init__(self, x, y, scale:float = 10., slope_left:float = 0., slope_right:float = 99999.):
332
+ """
333
+ :param x: np.ndarray or list of floats, x values of the points
334
+ :param y: np.ndarray or list of floats, y values of the points
335
+ :param scale: float, scale of the sigmoid functions
336
+ """
337
+
338
+ self.x:np.ndarray = np.asarray(x, dtype= np.float64).flatten()
339
+ self.y:np.ndarray = np.asarray(y, dtype= np.float64).flatten()
340
+ self.scale:np.float64 = np.float64(scale)
341
+ self.slope_left:np.float64 = slope_left
342
+ self.slope_right:np.float64 = slope_right
343
+
344
+ self._checked_x = False
345
+ self._checked_y = False
346
+
347
+ @property
348
+ def slope_left(self):
349
+ return self._slope_left
350
+
351
+ @property
352
+ def slope_right(self):
353
+ return self._slope_right
354
+
355
+ @slope_left.setter
356
+ def slope_left(self, value):
357
+ self._slope_left = np.float64(value)
358
+
359
+ if self._slope_left == 99999.:
360
+ self._slope_left = (self.y[1] - self.y[0]) / (self.x[1] - self.x[0])
361
+
362
+ @slope_right.setter
363
+ def slope_right(self, value):
364
+ self._slope_right = np.float64(value)
365
+
366
+ if self._slope_right == 99999.:
367
+ self._slope_right = (self.y[-1] - self.y[-2]) / (self.x[-1] - self.x[-2])
368
+
369
+
370
+ def clip_around_x(self, x:np.ndarray, nb_elemt:int = -1) -> tuple[np.ndarray, np.ndarray]:
371
+ """
372
+ Clip the input array x around the existing x values
373
+
374
+ :param x: np.ndarray, list or float, x values to clip
375
+ :param nb_elemt: int, number of elements to consider around each x value
376
+ :return: tuple of two np.ndarrays, clipped x and y values
377
+ """
378
+ if not self._checked_x:
379
+ self.check_x()
380
+ self._checked_x = True
381
+
382
+ x_array = np.asarray(x, dtype=np.float64).flatten()
383
+ return extract_xy_binary_search(x_array, self.x, self.y, np.int64(nb_elemt))
384
+
385
+ def clip_around_y(self, y:np.ndarray, nb_elemt:int = -1) -> tuple[np.ndarray, np.ndarray]:
386
+ """
387
+ Clip the input array y around the existing y values
388
+
389
+ :param y: np.ndarray, list or float, y values to clip
390
+ :param nb_elemt: int, number of elements to consider around each y value
391
+ :return: tuple of two np.ndarrays, clipped x and y values
392
+ """
393
+ if not self._checked_y:
394
+ self.check_y()
395
+ self._checked_y = True
396
+
397
+ y_array = np.asarray(y, dtype=np.float64).flatten()
398
+ return extract_xy_binary_search(y_array, self.y, self.x, np.int64(nb_elemt))
399
+
400
+ def gradient(self, x, approximative:bool = False, nb_elemt:int = -1) -> np.ndarray:
401
+ """ Gradient of the piecewise linear function with smooth transitions using sigmoids
402
+
403
+ :param x: np.ndarray, list or float, x values to evaluate the gradient
404
+ :param approximative: bool, if True, use an approximative gradient (ignoring derivative of the sigmoids)
405
+ :param nb_elemt: int, number of elements to consider around the x_candidate value
406
+ """
407
+
408
+ if not self._checked_x:
409
+ self.check_x()
410
+ self._checked_x = True
411
+
412
+ if approximative:
413
+ if isinstance(x, np.ndarray):
414
+ return gradient_piecewise_linear_approx(x.astype(np.float64), self.x, self.y, self.scale, np.int64(nb_elemt), self.slope_left, self._slope_right)
415
+ elif isinstance(x, list):
416
+ return gradient_piecewise_linear_approx(np.array(x, dtype=np.float64), self.x, self.y, self.scale, np.int64(nb_elemt), self.slope_left, self._slope_right)
417
+ elif isinstance(x, float):
418
+ return gradient_piecewise_linear_approx(np.array([x], dtype=np.float64), self.x, self.y, self.scale, np.int64(nb_elemt), self.slope_left, self._slope_right)[0]
419
+ elif isinstance(x, int):
420
+ return gradient_piecewise_linear_approx(np.array([float(x)], dtype=np.float64), self.x, self.y, self.scale, np.int64(nb_elemt), self.slope_left, self._slope_right)[0]
421
+ else:
422
+ raise ValueError('x should be np.ndarray, list or float')
423
+ else:
424
+ if isinstance(x, np.ndarray):
425
+ return gradient_piecewise_linear(x.astype(np.float64), self.x, self.y, self.scale, np.int64(nb_elemt), self.slope_left, self._slope_right)
426
+ elif isinstance(x, list):
427
+ return gradient_piecewise_linear(np.array(x, dtype=np.float64), self.x, self.y, self.scale, np.int64(nb_elemt), self.slope_left, self._slope_right)
428
+ elif isinstance(x, float):
429
+ return gradient_piecewise_linear(np.array([x], dtype=np.float64), self.x, self.y, self.scale, np.int64(nb_elemt), self.slope_left, self._slope_right)[0]
430
+ elif isinstance(x, int):
431
+ return gradient_piecewise_linear(np.array([float(x)], dtype=np.float64), self.x, self.y, self.scale, np.int64(nb_elemt), self.slope_left, self._slope_right)[0]
432
+ else:
433
+ raise ValueError('x should be np.ndarray, list or float')
434
+
435
+ def check_x(self):
436
+ diff = np.diff(self.x)
437
+ if np.any(diff <= 0):
438
+ raise ValueError('x should be in increasing order')
439
+
440
+ def check_y(self):
441
+ diff = np.diff(self.y)
442
+ if np.any(diff <= 0):
443
+ raise ValueError('y should be in increasing order')
444
+
445
+ def check(self):
446
+ self.check_x()
447
+ self.check_y()
448
+
449
+ return True
450
+
451
+ @property
452
+ def n(self):
453
+ """ Number of points """
454
+ return len(self.x)
455
+
456
+ @property
457
+ def size(self):
458
+ return self.n
459
+
460
+ def __call__(self, x, nb_elemt:int = -1) -> np.ndarray:
461
+ """ Evaluate the piecewise linear function at x
462
+
463
+ :param x: np.ndarray, list or float, x values to evaluate the function
464
+ :param nb_elemt: int, number of elements to consider around the x_candidate value
465
+ """
466
+
467
+ if not self._checked_x:
468
+ self.check_x()
469
+ self._checked_x = True
470
+
471
+ if isinstance(x, np.ndarray):
472
+ return piecewise_linear(x.astype(np.float64), self.x, self.y, self.scale, np.int64(nb_elemt), self._slope_left, self._slope_right)
473
+ elif isinstance(x, list):
474
+ return piecewise_linear(np.array(x, dtype=np.float64), self.x, self.y, self.scale, np.int64(nb_elemt), self._slope_left, self._slope_right)
475
+ elif isinstance(x, float):
476
+ return piecewise_linear(np.array([x], dtype=np.float64), self.x, self.y, self.scale, np.int64(nb_elemt), self._slope_left, self._slope_right)[0]
477
+ elif isinstance(x, int):
478
+ return piecewise_linear(np.array([float(x)], dtype=np.float64), self.x, self.y, self.scale, np.int64(nb_elemt), self._slope_left, self._slope_right)[0]
479
+ else:
480
+ raise ValueError('x should be np.ndarray, list or float')
481
+
482
+ def inverse(self, y, nb_elemt:int = -1) -> np.ndarray:
483
+ """ Evaluate the inverse of the piecewise linear function at y """
484
+
485
+ if not self._checked_y:
486
+ self.check_y()
487
+ self._checked_y = True
488
+
489
+ if isinstance(y, np.ndarray):
490
+ return piecewise_linear(y.astype(np.float64), self.y, self.x, self.scale, np.int64(nb_elemt), 1./self._slope_left, 1./self._slope_right)
491
+ elif isinstance(y, list):
492
+ return piecewise_linear(np.array(y, dtype=np.float64), self.y, self.x, self.scale, np.int64(nb_elemt), 1./self._slope_left, 1./self._slope_right)
493
+ elif isinstance(y, float):
494
+ return piecewise_linear(np.array([y], dtype=np.float64), self.y, self.x, self.scale, np.int64(nb_elemt), 1./self._slope_left, 1./self._slope_right)[0]
495
+ else:
496
+ raise ValueError('y should be np.ndarray, list or float')
497
+
498
+ def get_y(self, x, nb_elemt:int = -1):
499
+ """ Get the value of the piecewise linear function at x """
500
+ return self(x, nb_elemt)
501
+
502
+ def get_x(self, y, nb_elemt:int = -1):
503
+ """ Get the inverse of the piecewise linear function at y """
504
+ return self.inverse(y, nb_elemt)
505
+
506
+ @njit
507
+ def _polynomial(coeffs:np.ndarray, x:np.float64):
508
+ """ Polynomial function """
509
+ return np.sum(coeffs * np.power(x, np.arange(len(coeffs))), dtype=np.float64)
510
+
511
+ @njit
512
+ def piecewise_polynomial(x_candidate:np.ndarray, x:np.ndarray, y:np.ndarray, poly_coeff:np.ndarray,
513
+ scale:np.float64,
514
+ slope_left:np.float64, slope_right:np.float64) -> np.ndarray:
515
+ """ Piecewise polynomial function.
516
+
517
+ :param x_candidate: np.ndarray, list or float, x values to evaluate the function
518
+ :param x: np.ndarray, list of floats, x values of the transition points between polyomials functions
519
+ :param poly_coeff: np.ndarray, list of floats, coefficients of the polynomial functions
520
+ """
521
+
522
+ c_left = np.zeros(poly_coeff.shape[1], dtype=np.float64)
523
+ c_right = np.zeros(poly_coeff.shape[1], dtype=np.float64)
524
+
525
+ c_left[0] = y[0] - slope_left * x[0]
526
+ c_left[1] = slope_left
527
+
528
+ c_right[0] = y[-1] - slope_right * x[-1]
529
+ c_right[1] = slope_right
530
+
531
+ shape_max = max([len(coeffs) for coeffs in poly_coeff])
532
+ polys = np.zeros((len(poly_coeff)+2, shape_max), dtype=np.float64)
533
+
534
+ polys[0,:] = c_left
535
+ polys[-1,:] = c_right
536
+ polys[1:-1,:] = poly_coeff
537
+
538
+ # Extend the x and y values to allow extrapolation based on slope_left and slope_right
539
+ xx = np.zeros(x.shape[0]+1) # add the extrapolation points
540
+
541
+ xx[0] = x[0]
542
+ xx[1:] = x
543
+
544
+ x = xx
545
+
546
+ n = len(polys)
547
+ results = np.zeros(len(x_candidate))
548
+ sigmoids = np.ones(n)
549
+ one_minus_sigmoids = np.ones(n)
550
+
551
+ for idx, cur_x in enumerate(x_candidate):
552
+ xvals = np.full(n, cur_x)
553
+ sigmoids[1:] = sigmoid(xvals[1:], x[1:], scale)
554
+ one_minus_sigmoids[:-1] = 1. - sigmoids[1:]
555
+
556
+ for i in range(n):
557
+ results[idx] += sigmoids[i] * one_minus_sigmoids[i] * _polynomial(polys[i], cur_x)
558
+
559
+ return results
560
+
561
+
562
+ class Piecewise_Polynomial_Sigmoid():
563
+ """ Polynomial function with smooth transitions using sigmoids """
564
+
565
+ def __init__(self, x, y, scale:float = 10., degree:int = 3, slope_left:float = 0., slope_right:float = 99999.):
566
+
567
+ self.x:np.ndarray = np.asarray(x, dtype= np.float64).flatten()
568
+ self.y:np.ndarray = np.asarray(y, dtype= np.float64).flatten()
569
+ self.scale:np.float64 = np.float64(scale)
570
+ self.degree:int = degree
571
+
572
+ self.slope_left:np.float64 = slope_left
573
+ self.slope_right:np.float64 = slope_right
574
+
575
+ self._poly_coeff = None
576
+ self._parts_x = None
577
+ self._parts_y = None
578
+
579
+ self._checked_x = False
580
+ self._checked_y = False
581
+
582
+ @property
583
+ def slope_left(self):
584
+ return self._slope_left
585
+
586
+ @property
587
+ def slope_right(self):
588
+ return self._slope_right
589
+
590
+ @slope_left.setter
591
+ def slope_left(self, value):
592
+ self._slope_left = np.float64(value)
593
+
594
+ if self._slope_left == 99999.:
595
+ self._slope_left = (self.y[1] - self.y[0]) / (self.x[1] - self.x[0])
596
+
597
+ @slope_right.setter
598
+ def slope_right(self, value):
599
+ self._slope_right = np.float64(value)
600
+
601
+ if self._slope_right == 99999.:
602
+ self._slope_right = (self.y[-1] - self.y[-2]) / (self.x[-1] - self.x[-2])
603
+
604
+ def fit(self, forced_passage:np.ndarray, method:str = 'Nelder-Mead') -> np.ndarray:
605
+ """ Convert XY points into nbparts polynomial segments.
606
+
607
+ The segments are combined with sigmoids to ensure a smooth transition.
608
+
609
+ The routine must find the best transition points to minimize error.
610
+ """
611
+
612
+ def error(coeffs, x, y, xx, yy, degree):
613
+ nbparts = len(xx) - 1
614
+ loc_coeffs = np.reshape(coeffs, (nbparts, degree+1))
615
+ return np.sum((y - piecewise_polynomial(x, xx, yy, loc_coeffs, self.scale, self.slope_left, self.slope_right))**2)
616
+
617
+ # Fit the polynomals coefficients
618
+ nbparts = forced_passage.shape[0] - 1
619
+ coeffs = np.zeros((nbparts, self.degree+1), dtype=np.float64)
620
+ coeffs[:,0] = 0.
621
+ coeffs[:,1] = 1.
622
+
623
+ self._parts_x = forced_passage[:,0]
624
+ self._parts_y = forced_passage[:,1]
625
+
626
+ ret = minimize(error, args=(self.x, self.y,
627
+ forced_passage[:,0], forced_passage[:,1],
628
+ self.degree), x0=coeffs.flatten(),
629
+ method=method, tol= 1.e-14)
630
+
631
+ self._poly_coeff = ret.x.reshape((nbparts, self.degree+1))
632
+
633
+ return self._poly_coeff
634
+
635
+ def fit_null_first_term(self, forced_passage:np.ndarray, method:str = 'Nelder-Mead') -> np.ndarray:
636
+ """ Convert XY points into nbparts polynomial segments.
637
+
638
+ The segments are combined with sigmoids to ensure a smooth transition.
639
+
640
+ The routine must find the best transition points to minimize error.
641
+ """
642
+
643
+ def error(coeffs, x, y, xx, yy, degree):
644
+ nbparts = len(xx) - 1
645
+ loc_coeffs = np.zeros((nbparts, degree+1), dtype=np.float64).flatten()
646
+ loc_coeffs[1:] = coeffs
647
+ loc_coeffs = np.reshape(loc_coeffs, (nbparts, degree+1))
648
+
649
+ return np.sum((y - piecewise_polynomial(x, xx, yy, loc_coeffs, self.scale, self.slope_left, self.slope_right))**2)
650
+
651
+ # Fit the polynomals coefficients
652
+ nbparts = forced_passage.shape[0] - 1
653
+ coeffs = np.zeros((nbparts, self.degree+1), dtype=np.float64)
654
+ coeffs[:,1] = (self.y[-1] - self.y[0]) / (self.x[-1] - self.x[0])
655
+
656
+ self._parts_x = forced_passage[:,0]
657
+ self._parts_y = forced_passage[:,1]
658
+
659
+ ret = minimize(error, args=(self.x, self.y,
660
+ forced_passage[:,0], forced_passage[:,1],
661
+ self.degree), x0=coeffs.flatten()[1:],
662
+ method=method, tol= 1.e-14,
663
+ options={'maxiter': 100000})
664
+
665
+ self._poly_coeff = np.zeros((nbparts, self.degree+1), dtype=np.float64).flatten()
666
+ self._poly_coeff[1:] = ret.x
667
+ self._poly_coeff = self._poly_coeff.reshape((nbparts, self.degree+1))
668
+
669
+ return self._poly_coeff
670
+
671
+ def check_x(self):
672
+ diff = np.diff(self.x)
673
+ if np.any(diff <= 0):
674
+ raise ValueError('x should be in increasing order')
675
+
676
+ def check_y(self):
677
+ diff = np.diff(self.y)
678
+ if np.any(diff <= 0):
679
+ raise ValueError('y should be in increasing order')
680
+
681
+ def check(self):
682
+ self.check_x()
683
+ self.check_y()
684
+
685
+ return True
686
+
687
+ @property
688
+ def n(self):
689
+ """ Number of points """
690
+ return len(self.x)
691
+
692
+ @property
693
+ def size(self):
694
+ return self.n
695
+
696
+ def __call__(self, x, nb_elemt:int = -1) -> np.ndarray:
697
+ """ Evaluate the piecewise linear function at x
698
+
699
+ :param x: np.ndarray, list or float, x values to evaluate the function
700
+ :param nb_elemt: int, number of elements to consider around the x_candidate value
701
+ """
702
+
703
+ if not self._checked_x:
704
+ self.check_x()
705
+ self._checked_x = True
706
+
707
+ if isinstance(x, np.ndarray):
708
+ return piecewise_polynomial(x.astype(np.float64), self._parts_x, self._parts_y, self._poly_coeff, self.scale, self._slope_left, self._slope_right)
709
+ elif isinstance(x, list):
710
+ return piecewise_polynomial(np.array(x, dtype=np.float64), self._parts_x, self._parts_y, self._poly_coeff, self.scale, self._slope_left, self._slope_right)
711
+ elif isinstance(x, float):
712
+ return piecewise_polynomial(np.array([x], dtype=np.float64), self._parts_x, self._parts_y, self._poly_coeff, self.scale, self._slope_left, self._slope_right)[0]
713
+ elif isinstance(x, int):
714
+ return piecewise_polynomial(np.array([float(x)], dtype=np.float64), self._parts_x, self._parts_y, self._poly_coeff, self.scale, self._slope_left, self._slope_right)[0]
715
+ else:
716
+ raise ValueError('x should be np.ndarray, list or float')
717
+
718
+ def inverse(self, y, nb_elemt:int = -1) -> np.ndarray:
719
+ """ Evaluate the inverse of the piecewise linear function at y """
720
+
721
+ logging.warning('Inverse function not implemented yet')
722
+ pass
723
+
724
+ def get_y(self, x, nb_elemt:int = -1):
725
+ """ Get the value of the piecewise linear function at x """
726
+ return self(x, nb_elemt)
727
+
728
+ def get_y_symmetry(self, x:np.ndarray, nb_elemt:int = -1):
729
+
730
+ idx_sup = np.where(x > self.x[-1])[0]
731
+
732
+ xloc = np.where(x <= self.x[-1], x, 2. * self.x[-1] - x)
733
+ yloc = self(xloc, nb_elemt)
734
+
735
+ if len(idx_sup) > 0:
736
+ yloc[idx_sup] = 2. * self.y[-1] - yloc[idx_sup]
737
+
738
+ return yloc
739
+
740
+ def get_x(self, y, nb_elemt:int = -1):
741
+ """ Get the inverse of the piecewise linear function at y """
742
+ return self.inverse(y, nb_elemt)
743
+
744
+ if __name__ == '__main__':
745
+
746
+ import matplotlib.pyplot as plt
747
+
748
+ test_sigmoid = sigmoid(0., 0, 10)
749
+
750
+ x = np.asarray([-2., 0, 1., 2., 4., 5.], dtype=np.float64)
751
+ y = np.asarray([0., 0., 2., 8., -2., 5.], dtype=np.float64)
752
+ x_test = np.linspace(-2, 6, 1000)
753
+
754
+ x = np.arange(1000)
755
+ y = np.sort(np.random.randn(1000))
756
+
757
+ newx, newy = extract_xy_binary_search(x_test, x, y, np.int64(100))
758
+
759
+ x_test = np.linspace(5, 1000, 100)
760
+
761
+ fig, ax = plt.subplots()
762
+ ax.plot(x, y, 'o')
763
+
764
+ for scale in [0.01,.1,10.,100.]:
765
+ # for scale in [100.]:
766
+ xy = Piecewise_Linear_Sigmoid(x, y, scale)
767
+ ax.plot(x_test, xy(x_test, nb_elemt= 5), label=f'scale={scale}')
768
+ # ax.plot(x_test, xy.gradient(x_test), label=f'gradient={scale}')
769
+ ax.plot(x_test, xy.gradient(x_test, True, nb_elemt= 5), label=f'gradient_approx={scale}')
770
+
771
+ ax.legend()
772
+ fig.show()
773
+
774
+ plt.show()
775
+
776
+ pass