Question about Functors

I am trying to solve an economic model. The algorithm involves an iterative two-step procedure, like a back-and-forth optimization. There are lots of inputs and moving parts. I created a struct to keep track of it all. What follows involves psuedocode rather than a MWE.

struct MyModel
A
B
C
D
end

The algorithm works like this: (1) holding B fixed, solve for new A, (2) holding new A fixed, solve for new B. Repeat. Each step takes as input potentially all fields of MyModel (and there many and of various types).

I created a functor that when called should initiate the algorithm and produce the solution (which is just A and B after convergence). However, inside that function I call functions that do each step of the algorithm – also in place.

function updateA!(m::MyModel)
# do stuff to get newA
# updates A in place (using Setfield.jl)
@set m.A = newA
return nothing
end

function updateB!(m::MyModel)
# do stuff to get newB
# updates B in place (using Setfield.jl)
@set m.B = newB
return nothing
end

So that the functor method actually is like this

function (m::MyModel)(;kw...)
chg = Inf
while chg > tolerance
   updateA!(m)
   updateB!(m)
   chg = ...
end
# A and B updated in place so returns nothing
return nothing
end

I am wondering what happens in this situation. Is it really using the same m object everywhere? Do I need to worry about unexpected behaviors here, which could be very very difficult to debug? I think of functors as operating on or within themselves (perhaps this is completely incorrect), so it feels weird to “pass” m to another mutating function as part of this process.

My initial goal of this approach was to keep the code clean, to eliminate unnecessary creation of temporary variables (eg. elements C, D, etc. are shared between the two steps), and ultimately to have this be performant. I am open to suggestions if this is a poor design decision.

1 Like

That isn’t what Setfield.jl does.
It doesn’t let you mutate immutable objects.
It returns new objects with the given points updated.

e.g.

julia> using Setfield
[ Info: Precompiling Setfield [efcf1570-3423-57d1-acb7-fd33fddbac46]

julia> struct Foo
       a
       b
       end

julia> foo1 = Foo(1,10)
Foo(1, 10)

julia> @set foo1.a=2
Foo(2, 10)

julia> foo1
Foo(1, 10)

So you can’t be returning nothing

1 Like

This does not seem very nice.

The use of immutable datastructures come from the goal of mimimizing the amount of state you are keeping.
State is hard to think about.
So one tries to write code that only assigned each variable once, and never updates it.
Updating a variable cases that same variable “means” something different in a different part of the code.
Thus your code gets a implict depenence order, so you can’t move lines about without working out where state changes occur.
Where as if you only assign each once, you can move lines about, but if you move them such that something isn’t assigned before it is used, you get an error.

Creation of temporary variables is cheap (free infact).
It is the allocation of memory (esp on the heap) that is (moderately) expensive.

You can just have local variables C and D and pass them into the functions that need them.
This is clearer also, because now its transperent that those functions need those inputs (and not anything else you didn’t pass to it).
So one can look at it and see what it used.

1 Like

Eh, I didn’t notice that yet about Setfield.jl. Thanks for letting me know.

Regarding the other stuff, C and D are just stand ins. The actual number of things is nearing 20. It is stuff that I precompute so that the actual updateA!-type functions do a minimal amount of work. Perhaps I could separate that off (the stuff that is never updated once created) to be a different struct that gets passed around. I want to keep as much of it together as possible because it is not just used in the solution. Lots of it will continue to be in use afterwards in simulation and testing.

mutable struct EconomicProblem{A, B, C, D, ...}
  A
  B
  C
  D
  ...
end
function solve!(model::EconomicProblem)
  Δ = Inf
  while Δ > tolerance
    Δ₀ = updateA!(m)
    Δ₁ = updateB!(m)
    Δ = ...
  end
end

That’s a pretty natural structure and sound strategy. I would use a mutable struct for updating the values in-place. I would also make he updateX! methods return the components to determine convergence directly. For performance, either specify the types in the struct definition or if they might differ or use parametrization.

1 Like

In principle, you can make this work, and it is the same m inside the mutating callable.

But in practice, I would suggest using a functional style:

  1. group fixed parameters (eg C, D, …) into an (immutable) struct, which we call params below,

  2. define solve_for_A(params, B) to get A, and similarly solve_for_B(params, A),

  3. write a function solve_iteratively(params; A = initial_guess_A(params)) that performs the loop above.

Code like this is usually easier to reason about. You can later switch to re-using pre-allocated A and B, eg solve_for_A!(A, params, B), but the performance gains are usually not worth the complexity.

3 Likes

Oh thats really unfortunate.
There are no good software engineering answers if you are in this space.
Because of the whole “humans can only keep about 7 things in working memory” thing.

The normal software engineering advice would be to rearrange your program such that each function only had at most 7 variables.
Which one migh accomplish by drawing the boundries between functions differently.
Or by grouping some into structs with 7 or fewer fields, where that struct could be thought of as a single entitiy.
e.g. the train function takes a single argument optimizer_settings which it passes to the optimizer without looking inside (thus counting as 1). The optimizer takes that and does look inside counting as (say) 5 of its variables, with the loss and the parameters being tuned making uop to 7.

But I do appreciate this may not be a reasonable expectation for some scientific code.

Good luck.

3 Likes

Great replies while I slept – I should sleep more often!

Thanks for the suggestions. I will update my design accordingly.

1 Like