flex-cartesian 1.3.0 → 2.0.0.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +4 -0
- data/LICENSE +6 -0
- data/README.md +133 -439
- data/lib/analyzer.rb +48 -0
- data/lib/analyzers/morris.rb +268 -0
- data/lib/flex-cartesian/flex-cartesian-analyzer.rb +16 -0
- data/lib/flex-cartesian/flex-cartesian-core.rb +624 -0
- data/lib/flex-cartesian/flex-cartesian-deprecations.rb +13 -0
- data/lib/flex-cartesian/flex-cartesian-io.rb +192 -0
- data/lib/flex-cartesian/flex-cartesian-utilities.rb +126 -0
- data/lib/flex-cartesian.rb +13 -331
- data/lib/version.rb +3 -0
- data/lib/visualization/html.rb +217 -0
- metadata +48 -26
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
|
+
|