Сообщество Engee

Продвинутая разработка блоков Engee

Автор
avatar-nikfilaretovnikfilaretov
Notebook

Продвинутая разработка блоков на основе Engee Function

Описание проекта

Мы уже научились разрабатывать свои блоки при помощи Engee Function.

А что делать, если алгоритм, который надо реализовать, сложный и громоздкий? Сразу возникают проблемы:

  • Код Engee Function получается раздутый и его попросту тяжело читать

  • Непонятно, как тестировать такой код. Если возникает ошибка, то локализовать ее сложно

Решение этих проблем - это не "ракетная наука", а рутинная работа для программистов. Поэтому давайте побудем немного ими!

Вот как мы будем решать проблемы громоздкого кода:

  1. Выделим алгоритм в отдельный модуль

  2. Покроем модуль тестами

  3. Повысим устойчивость кода

В качестве примера рассмотрим алгоритм нахождения расстояния между двумя наборами наблюдений. А для работы нам потребуются следующие пакеты:

In [ ]:
import Pkg
Pkg.add(["LinearAlgebra", "Test", "BenchmarkTools"])
   Resolving package versions...
  No Changes to `~/.project/Project.toml`
  No Changes to `~/.project/Manifest.toml`

Вынос кода в модуль

Модуль в контексте Julia - это код, который заключен в отдельное пространство имен. Это позволяет делать переменные внутри этого модуля "невидимыми" вне его. Чтобы подробнее узнать о преимуществах модулей обратитесь к справке.

Посмотрим на код нашего алгоритма, заключенного в модуль:

In [ ]:
;cat PDIST2.jl
module PDIST2

function EF_pdist2(X::Matrix{Float64}, Y::Matrix{Float64}; metric::String="euclidean")
    m, n = size(X)
    p, n2 = size(Y)
    n == n2 || throw(DimensionMismatch("Number of columns in X and Y must match"))

    if metric == "euclidean"
        XX = sum(X.^2, dims=2)
        YY = sum(Y.^2, dims=2)
        D = XX .+ YY' .- 2 .* (X * Y')
        return sqrt.(max.(D, 0))
    
    elseif metric == "squaredeuclidean"
        XX = sum(X.^2, dims=2)
        YY = sum(Y.^2, dims=2)
        D = XX .+ YY' .- 2 .* (X * Y')
        return max.(D, 0)
    
    elseif metric == "manhattan"
        D = zeros(m, p)
        for j in 1:p
            for i in 1:m
                D[i, j] = sum(abs.(X[i, :] .- Y[j, :]))
            end
        end
        return D
    
    elseif metric == "cosine"
        XX = sqrt.(sum(X.^2, dims=2))
        YY = sqrt.(sum(Y.^2, dims=2))
        norms = XX .* YY'
        XY = X * Y'
        
        # Handle division by zero: set invalid entries to 0, then correct cases where both vectors are zero
        sim = zeros(size(XY))
        valid = norms .> 0
        sim[valid] .= XY[valid] ./ norms[valid]
        
        # Identify pairs where both vectors are zero (cosine similarity = 1)
        both_zero = (XX .== 0) .& (YY' .== 0)
        sim[both_zero] .= 1
        
        return 1 .- sim
    
    else
        throw(ArgumentError("Unknown metric: $metric. Supported metrics are 'euclidean', 'squaredeuclidean', 'manhattan', 'cosine'"))
    end
end


end

Тестирование

Цель тестирования - доказать, то что код работает правильно, ловятся все исключения.

Тесты пишутся с помощью пакета Test.jl

Создадим три простых теста:

  • Простой вызов функции

  • Обработка разных размерностей

  • Корректность вычисления дистанций между одинаковыми матрицами

Поместим эти тесты в набор тестов, который задается как:

@testset <имя_набора> begin
<тесты>
end

Особенность тестов в пакете Test.jl заключается в том, что макросы тестирования проверяют истинность некоего выражения. Посмотрим на пример:

In [ ]:
using Test, LinearAlgebra

demoroot = @__DIR__

include("PDIST2.jl")

X = rand(3,3)
Y = rand(3,3)
Z = PDIST2.EF_pdist2(X,Y);
@testset "EF_pdist2 tests" begin
    @test_nowarn PDIST2.EF_pdist2(X,Y);
    @test_throws DimensionMismatch PDIST2.EF_pdist2(rand(3,3),rand(2,2))
    @test iszero(diag(PDIST2.EF_pdist2(X,X)))
end
Test Summary:   | Pass  Total  Time
EF_pdist2 tests |    3      3  0.8s
Out[0]:
Test.DefaultTestSet("EF_pdist2 tests", Any[], 3, false, false, true, 1.754979865285884e9, 1.754979866072697e9, false, "In[3]")

Оценка производительности

Чтобы оценить общую производительность кода требуется замерять несколько показателей:

  • Скорость исполнения

  • Количество выделяемой памяти

  • Количество аллокаций (специфично для Julia)

Для этого будем использовать пакет BenchmarkTools.jl. Его преимущество заключается в том, что можно как тонко настроить эксперимент, так и сразу приступить к замерам:

In [ ]:
using BenchmarkTools

@benchmark PDIST2.EF_pdist2(rand(10,10),rand(10,10))
Out[0]:
BenchmarkTools.Trial: 10000 samples with 8 evaluations per sample.
 Range (minmax):  3.033 μs 9.495 ms   GC (min … max):  0.00% … 99.82%
 Time  (median):     7.843 μs               GC (median):     0.00%
 Time  (mean ± σ):   9.099 μs ± 95.130 μs   GC (mean ± σ):  10.42% ±  1.00%

                      ▁▄▇██▄▂▁                               
  ▂▃▃▂▂▁▁▁▁▁▁▁▁▁▁▁▁▁▂▅████████▇▆▄▃▃▂▂▂▂▂▂▂▂▂▁▁▂▁▁▁▁▁▁▁▁▁▁▁ ▂
  3.03 μs        Histogram: frequency by time        14.2 μs <

 Memory estimate: 6.77 KiB, allocs estimate: 18.

Для нас важны следующие измерения: Time и Memory estimate.

Time - это время выполнения одного прогона. Так как @benchmark запускает несколько прогонов, то мы получаем набор таких измерений и можем применить к нему статистическую обработку. Ее результаты показываются после запуска макроса @benchmark, как показано выше

Memory Estimate покажет объем выделяемой памяти. Давайте посмотрим, как растет время исполнения и объем памяти по мере увеличения объема входа:

In [ ]:
matrix_size = 80 # @param {type:"slider",min:10,max:100,step:10}
@benchmark PDIST2.EF_pdist2(rand(matrix_size,matrix_size),rand(matrix_size,matrix_size))
Out[0]:
BenchmarkTools.Trial: 10000 samples with 1 evaluation per sample.
 Range (minmax):   96.698 μs 23.746 ms   GC (min … max):  0.00% … 98.90%
 Time  (median):     116.496 μs                GC (median):     0.00%
 Time  (mean ± σ):   172.030 μs ± 430.871 μs   GC (mean ± σ):  13.25% ±  6.23%

  ▁▇█▆▆▄▂▁                                            ▁▃▃▂▂▁   ▂
  ██████████▇▇▆▆▄▃▁▃▁▁▁▃▁▁▁▁▁▁▁▁▁▁▁▃▆▇█▇▄▄▁▁▁▃▅▇▇▆▆▄▇███████▇ █
  96.7 μs       Histogram: log(frequency) by time        494 μs <

 Memory estimate: 352.01 KiB, allocs estimate: 25.

Устойчивость к отказам

Создаваемый код должен быть устойчивым к отказам. Для этого требуется работать с исключениями.

Исключение, в общем смысле - это какая-либо ошибка, которую можно обработать. Главное отличие от обычных ошибок (синтаксических) - то, что пользователь сам может генерировать исключения. Посмотрим на код нашей функции pdist2:

 m, n = size(X)
 p, n2 = size(Y)
 n == n2 || throw(DimensionMismatch("Number of columns in X and Y must match"))

Если размерности матриц не совпадают, то вызывается функция throw и "бросается" исключение.

Исключение можно обработать при помощи конструкции

try
catch
end

в блок try помещается вызов какого-либо кода. Если этот код порождает исключение, то будет выполнен код из блока catch.

Использование библиотеки в Engee Function

В качестве примера будем использовать модель EF_dist_find:

image.png

In [ ]:
mdl = engee.open(joinpath(demoroot,"EF_dist_find.engee"));

Рассмотрим конструктор блока PDIST2:

include("/user/start/examples/base_simulation/advanced_block_development/PDIST2.jl")

mutable struct Block <: AbstractCausalComponent
cache::Matrix{Float64};
function Block()
    c = zeros(Float64,INPUT_SIGNAL_ATTRIBUTES[1].dimensions);
    info("Allocated $(Base.summarysize(c)) bytes for pdist2")
    new(c)
end

end

Мы включаем наш модуль в Engee Function, указывая полный путь до его кода.

При помощи функции info() будем выводить количество памяти, выделяемой при создании кеша.

Рассмотрим метод Step этого блока:

function (c::Block)(t::Real, in1, in2)    
    try
        c.cache = PDIST2.EF_pdist2(in1,in2);
    catch
        error("Matrix Dimensions should be equal!")
        stop_simulation()
    end

    return c.cache
end

Обратим внимание на то, что вызов функции из нашего модуля обернут в обработчик исключений. Если наша функция pdist2 породит исключение, то в окне диагностики появится ошибка "Matrix Dimensions should be equal!", а симуляция будет остановлена.

При открытии создаются две матрицы 3x3 со случайными числами. Убедимся в том, что модель работает:

In [ ]:
engee.run(mdl)
Out[0]:
SimulationResult(
    "PDIST2.1" => WorkspaceArray{Matrix{Float64}}("EF_dist_find/PDIST2.1")

)

Выводы

В проекте был показан подход к созданию блока на основе Engee Function, обеспечивающий простоту отладки и повышающий качество и надежность кода.