flex-cartesian 1.3.1 → 2.0.1.beta

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.
data/lib/analyzer.rb ADDED
@@ -0,0 +1,48 @@
1
+ class Analyzer
2
+
3
+ attr_reader :space, :names, :levels
4
+ attr_reader :name, :description, :url, :complexity, :category
5
+
6
+ def initialize(space)
7
+ @space = space
8
+ @struct = @space.struct
9
+ card
10
+ end
11
+
12
+ def card
13
+ raise NotImplementedError, "#{self.class} must implement #card"
14
+ end
15
+
16
+ def results
17
+ @space.function_results
18
+ end
19
+
20
+ def dimensions
21
+ @space.dimensions
22
+ end
23
+
24
+ def vector(**opts)
25
+ @space.vector(**opts)
26
+ end
27
+
28
+ def names
29
+ @space.names
30
+ end
31
+
32
+ def levels
33
+ @space.levels
34
+ end
35
+
36
+ def cartesian(&blk)
37
+ @space.cartesian(&blk)
38
+ end
39
+
40
+ def output
41
+ raise "Method must be implemented in a child class"
42
+ end
43
+ # def sensitivity(function:, **opts)
44
+ # rows = sensitivity(function: function)
45
+ # @space.output(rows, **opts)
46
+ # end
47
+
48
+ end
@@ -0,0 +1,268 @@
1
+ class Morris < Analyzer
2
+ Edge = Struct.new(:from_idx, :to_idx, :factor, :step)
3
+
4
+ attr_reader :trajectories, :step, :seed, :edges
5
+
6
+ def initialize(fc, trajectories:, step: 0.1, seed: nil)
7
+ super(fc)
8
+
9
+ @trajectories = trajectories
10
+ @step = step
11
+ @seed = seed
12
+ @rng = seed ? Random.new(seed) : Random.new
13
+ @points = []
14
+ @edges = []
15
+ @analysis = nil
16
+
17
+ validate_trajectories!
18
+ validate_step!
19
+ build!
20
+ end
21
+
22
+ def analyze(func:)
23
+ effects = Hash.new { |h, k| h[k] = [] }
24
+
25
+ @edges.each do |edge|
26
+ from_v = @points[edge.from_idx]
27
+ to_v = @points[edge.to_idx]
28
+
29
+ # Используем уже готовый хэш results из родительского контекста
30
+ from_res = results[from_v.to_h]
31
+ to_res = results[to_v.to_h]
32
+
33
+ next unless from_res && to_res
34
+
35
+ y1 = from_res[func]
36
+ y2 = to_res[func]
37
+
38
+ next if y1.nil? || y2.nil?
39
+
40
+ ee = (y2 - y1).to_f / edge.step
41
+ effects[edge.factor] << ee
42
+ end
43
+
44
+ # Iterate over names to consider constant parameters -
45
+ # the ones that has had no effect whatsoever
46
+ res = names.map do |factor|
47
+ ees = effects[factor]
48
+ n = ees.size
49
+
50
+ if n == 0
51
+ {
52
+ parameter: factor.to_s,
53
+ "influence[#{func}]": 0.0,
54
+ deviation: 0.0,
55
+ probes: 0
56
+ }
57
+ else
58
+ mean = ees.sum / n.to_f
59
+ importance = ees.map(&:abs).sum / n.to_f
60
+
61
+ variance =
62
+ if n > 1
63
+ ees.map { |e| (e - mean)**2 }.sum / (n - 1).to_f
64
+ else
65
+ 0.0
66
+ end
67
+
68
+ deviation = Math.sqrt(variance)
69
+
70
+ {
71
+ parameter: factor.to_s,
72
+ "influence[#{func}]": importance.round(2),
73
+ deviation: deviation.round(2),
74
+ probes: n
75
+ }
76
+ end
77
+ end.compact.sort_by { |row| -row[:"influence[#{func}]"] }
78
+
79
+ @analysis = res
80
+ res
81
+ end
82
+
83
+ def categorize(rows, function:)
84
+ return rows if rows.nil? || rows.empty?
85
+
86
+ # Берем значения напрямую из кэша results
87
+ all_y_values = results.values.map { |r| r[function] }.compact
88
+
89
+ y_range = if all_y_values.empty?
90
+ 1.0 # Protection from division by zero, if no data
91
+ else
92
+ range = all_y_values.max - all_y_values.min
93
+ range.zero? ? 1.0 : range # Protection from constant function
94
+ end
95
+
96
+ rows.map do |row|
97
+ imp = row[:"influence[#{function}]"]
98
+ sigma = row[:deviation]
99
+
100
+ influence_ratio = imp / y_range.to_f
101
+ strength =
102
+ if influence_ratio >= 0.10 # adds >= 10% of the deviation
103
+ "strong"
104
+ elsif influence_ratio >= 0.02 # 2% to 10%
105
+ "moderate"
106
+ else # less than 2%
107
+ "negligible"
108
+ end
109
+
110
+ linearity = "undefined"
111
+
112
+ if strength != "negligible" && imp > 0
113
+ ratio = sigma / imp
114
+ linearity =
115
+ if ratio < 0.5
116
+ "linear"
117
+ elsif ratio <= 1.0
118
+ "non-linear"
119
+ else
120
+ "highly non-linear"
121
+ end
122
+ end
123
+
124
+ row.merge(category: strength, linearity: linearity)
125
+ end
126
+ end
127
+
128
+ def recommend(rows, function:)
129
+ return rows if rows.nil? || rows.empty?
130
+
131
+ rows.map do |row|
132
+ recommendation =
133
+ case [row[:category], row[:linearity]]
134
+ when ["strong", "linear"]
135
+ "direct and predictable impact; prime candidate for gradient-based optimization"
136
+ when ["strong", "highly non-linear"]
137
+ "critical parameter with complex interactions; prioritize for variance-based analysis (e.g. Sobol)"
138
+ when ["strong", "non-linear"]
139
+ "important parameter; ensure sufficient grid density around expected operating zones"
140
+ when ["moderate", "linear"], ["moderate", "non-linear"], ["moderate", "highly non-linear"]
141
+ "secondary priority; fine-tune only after optimizing 'strong' parameters"
142
+ when ["negligible", "undefined"]
143
+ "fix at default or cheapest value to reduce dimensionality"
144
+ else
145
+ "review parameter configuration"
146
+ end
147
+
148
+ row.merge(recommendation: recommendation)
149
+ end
150
+ end
151
+
152
+ def output(func:, categorize: true, recommend: true, **opts)
153
+ raise ArgumentError, "target function must be provided" unless func
154
+ raise "Cannot execute #sensitivity as there are no functions defined in parameter space" if @space.derived.empty?
155
+
156
+ rows = @analysis.nil? ? analyze(func: func) : @analysis
157
+ rows = self.categorize(rows, function: func) if categorize
158
+ rows = self.recommend(rows, function: func) if recommend
159
+
160
+ @space.output(rows, **opts)
161
+ end
162
+
163
+ def card
164
+ @name = "Morris sensitivity analysis"
165
+ @description = "Morris method explores the parameter space by changing one parameter at a time across multiple trajectories, and quantifies rate and linearity of its influence on the target function"
166
+ @complexity = "O( dimensions · trajectories )"
167
+ @category = "Sensitivity analysis"
168
+ @url = "https://en.wikipedia.org/wiki/Morris_method"
169
+ end
170
+
171
+ private
172
+
173
+ # Теперь обращаемся к dimensions напрямую по имени измерения
174
+ def index_step_for(dimension_name)
175
+ levels_count = dimensions[dimension_name].size
176
+ return 0 if levels_count <= 1
177
+
178
+ intervals = levels_count - 1
179
+ calculated_step = (intervals * @step).round
180
+
181
+ [[calculated_step, 1].max, intervals].min
182
+ end
183
+
184
+ def build!
185
+ @trajectories.times do
186
+ build_trajectory!
187
+ end
188
+ end
189
+
190
+ def build_trajectory!
191
+ # Начинаем сразу с вектора, а не с массива индексов
192
+ current_v = random_start_point
193
+ from_idx = add_point(current_v)
194
+ return unless from_idx
195
+
196
+ # Выбираем факторы, по которым возможен хотя бы один шаг
197
+ active_factors = names.select { |dim| index_step_for(dim) > 0 }
198
+ factor_order = active_factors.shuffle(random: @rng)
199
+
200
+ factor_order.each do |factor|
201
+ step_size = index_step_for(factor)
202
+
203
+ # Делегируем сдвиг твоему методу: получаем новое значение для конкретного измерения
204
+ new_dim_value = vector(command: :shift, vector: current_v, dimension: factor, offset: step_size)
205
+ next if new_dim_value.nil?
206
+
207
+ # Собираем новый вектор, заменяя значение только для текущего фактора
208
+ next_values = names.map do |dim|
209
+ dim == factor ? new_dim_value : current_v.public_send(dim)
210
+ end
211
+ next_v = @struct.new(*next_values)
212
+
213
+ to_idx = add_point(next_v)
214
+ next unless to_idx
215
+
216
+ intervals = dimensions[factor].size - 1
217
+ relative_delta = step_size.to_f / intervals
218
+
219
+ @edges << Edge.new(
220
+ from_idx: from_idx,
221
+ to_idx: to_idx,
222
+ factor: factor,
223
+ step: relative_delta
224
+ )
225
+
226
+ current_v = next_v
227
+ from_idx = to_idx
228
+ end
229
+ end
230
+
231
+ # Формирует сразу готовый начальный вектор, а не индексы
232
+ def random_start_point
233
+ values = names.map do |dim|
234
+ levels = dimensions[dim]
235
+ step_size = index_step_for(dim)
236
+ max_start = levels.size - 1 - step_size
237
+
238
+ idx = if max_start < 0 || step_size == 0
239
+ @rng.rand(0...levels.size)
240
+ else
241
+ @rng.rand(0..max_start)
242
+ end
243
+ levels[idx]
244
+ end
245
+
246
+ @struct.new(*values)
247
+ end
248
+
249
+ # Сохраняет вектор и возвращает его индекс в @points
250
+ def add_point(point)
251
+ return nil unless @space.valid?(point)
252
+
253
+ @points << point
254
+ @points.size - 1
255
+ end
256
+
257
+ def validate_step!
258
+ unless @step.is_a?(Numeric) && @step > 0 && @step <= 1
259
+ raise ArgumentError, "step must be a relative float between 0.0 and 1.0 (e.g., 0.1 for 10%)"
260
+ end
261
+ end
262
+
263
+ def validate_trajectories!
264
+ unless @trajectories.is_a?(Integer) && @trajectories > 0
265
+ raise ArgumentError, "trajectories must be a positive integer"
266
+ end
267
+ end
268
+ end
@@ -0,0 +1,16 @@
1
+ require_relative '../analyzer'
2
+
3
+ module FlexCartesianAnalyzer
4
+
5
+ def analyzer(type, **opts)
6
+ case type
7
+ when :morris
8
+ require_relative '../analyzers/morris'
9
+ Morris.new(self, **opts)
10
+ else
11
+ raise ArgumentError, "Unknown analyzer: #{type}"
12
+ end
13
+ end
14
+
15
+ end
16
+