Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds @returned_quantities macro #696

Open
wants to merge 27 commits into
base: master
Choose a base branch
from

Conversation

torfjelde
Copy link
Member

@torfjelde torfjelde commented Oct 23, 2024

This adds the @returned_quantities macro as discussed @yebai @mhauru

This is meant to be a replacement for @submodel macro, but without the ability to do automatic prefixing. It ends up looking like

julia> @model function demo1(x)
           x ~ Normal()
           return 1 + abs(x)
       end;

julia> @model function demo2(x, y, z)
            a = @returned_quantities prefix(demo1(x), "sub1")
            b = @returned_quantities prefix(demo1(y), "sub2")
            return z ~ Uniform(-a, b)
       end;

julia> rand(demo2(missing, missing, 0.4))
(var"sub1.x" = 0.5865756059371534, var"sub2.x" = -0.25563799658500047)

Likely TODOs:

  • Add deprecation warning to @submodel telling the user to use @returned_quantities.
  • Do we do the renaming of generated_quantities to returned_quantities in this PR?

Fix #691

Copy link

codecov bot commented Oct 23, 2024

Codecov Report

Attention: Patch coverage is 93.75000% with 1 line in your changes missing coverage. Please review.

Project coverage is 77.78%. Comparing base (54691bf) to head (7aef65b).
Report is 3 commits behind head on master.

Files with missing lines Patch % Lines
src/contexts.jl 80.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #696      +/-   ##
==========================================
- Coverage   79.22%   77.78%   -1.45%     
==========================================
  Files          30       30              
  Lines        4212     3938     -274     
==========================================
- Hits         3337     3063     -274     
  Misses        875      875              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@coveralls
Copy link

coveralls commented Oct 23, 2024

Pull Request Test Coverage Report for Build 11627641486

Warning: This coverage report may be inaccurate.

This pull request's base commit is no longer the HEAD commit of its target branch. This means it includes changes from outside the original pull request, including, potentially, unrelated coverage changes.

Details

  • 15 of 16 (93.75%) changed or added relevant lines in 4 files are covered.
  • 62 unchanged lines in 2 files lost coverage.
  • Overall coverage decreased (-2.2%) to 77.447%

Changes Missing Coverage Covered Lines Changed/Added Lines %
src/contexts.jl 4 5 80.0%
Files with Coverage Reduction New Missed Lines %
src/utils.jl 23 82.48%
src/varinfo.jl 39 80.24%
Totals Coverage Status
Change from base Build 11381380435: -2.2%
Covered Lines: 3046
Relevant Lines: 3933

💛 - Coveralls

src/submodel_macro.jl Outdated Show resolved Hide resolved
@yebai
Copy link
Member

yebai commented Oct 24, 2024

@torfjelde I suggest we change the prefix feature to a prefix_variables model operation (feel free to come up with better names). Then we could use the same functionality prefix_variables in more places, e.g.

# submodel prefixing
julia> @model function demo2(x, y, z)
            a = @returned_quantities prefix_variables(demo1(x), "sub1")
            b = @returned_quantities prefix_variables(demo1(y), "sub2")
            return z ~ Uniform(-a, b)
       end;

julia> rand(demo2(missing, missing, 0.4))
(var"sub1.x" = 0.5865756059371534, var"sub2.x" = -0.25563799658500047)

# rand prefixing 

julia> ret = rand(prefix_variables(demo1(1.), "prior_sample"))

# generated quantities / predict 

julia> returned_quantities(prefix_variables(demo1(1.), "generated_var_"), chain) 

This would also help unify the syntax of @generated_qunatities and generated_quantities- IIRC, the only difference between them is that generated_quantities lacks the prefixing/renaming feature.

This could be further unified with NamedDist in the future. See, e.g., #414

@torfjelde
Copy link
Member Author

We already have DynamicPPL.prefix, though this doesn't do exactly what you want here. We could easily just add

prefix(model::Model, x) = contextualize(model, PrefixContext(model.context, Symbol(x)))

or something as an additional definition.

However, I'm a bit worred about

  1. It's quite verbose + a bit "too close to internals" for end-users.
  2. To achieve the same performance guarantees that we have currently, we need to wrap everything in Val before calling prefix(model, ...) 😕 This seems non-ideal to me vs. the current approach.

@yebai
Copy link
Member

yebai commented Oct 25, 2024

It's quite verbose + a bit "too close to internals" for end-users.

I like the @returned_quantities(prefix(model, "prefix_")) syntax because it is

  • less mysterious than @returned_quantities model "prefix_"
  • all the other model operations could share this, e.g. rand(prefix(model, "prefix_")) to verify the effects of prefixing, which is very useful

prefix(model, x) is NOT any closer to internals than any other model operation APIs. They are the same, so this is not a problem.

To achieve the same performance guarantees that we have currently, we need to wrap everything in Val before calling prefix(model, ...) 😕 This seems non-ideal to me vs. the current approach.

Point taken, but this is very minor and a bit subjective.

@torfjelde
Copy link
Member Author

torfjelde commented Oct 26, 2024

Point taken, but this is very minor and a bit subjective.

But this means that the user needs to be careful and do prefix(model, Val{:whatever}()); if we just do prefix(model, :whatever), this will lead to type-instabilities. Do we really want to force end-users of Turing.jl to explicitly use Val? 😕

@yebai
Copy link
Member

yebai commented Oct 27, 2024

It is a standard Julia performance trick, so it is okay.

By default, we can print a performance warning message when users call prefix(model, x::String) or similiar.

@yebai
Copy link
Member

yebai commented Oct 27, 2024

I'm also happy to turn prefix into a macro: @prefix(model, :prefix_) if that helps. Then we could do

@returned_quantities @prefix(model, :prefix_)

@torfjelde
Copy link
Member Author

Added a @prefix macro:) See the docstring of @returned_quantities for what it looks like 👍

torfjelde and others added 3 commits October 29, 2024 18:39
…cro' into torfjelde/returned-quantities-macro
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
@yebai
Copy link
Member

yebai commented Oct 30, 2024

Thanks, @torfjelde; I'm happy with the changes.

To minimise interface confusion (prefix vs. @prefix, and @returned_quantities vs. returned_quantities), shall we consider keeping only @prefix and @returned_quantities and depreciating generated_quantities and prefix?

Thoughts? @mhauru and @penelopeysm

Copy link
Member

@mhauru mhauru left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For prefix/@prefix, maybe keep both but only export the macro? It sounds like unless you know what you are doing, you should use @prefix. And if you know what you're doing, you don't need it be exported. I do generally think it's a good idea to have a macro-free option available if possible.

For returned_quantities/@returned_quantities we still need both, because one is to be used outside of @model, the other inside, right? I forget what we concluded about this in our call, but I do worry users will mix the two up and get confusing errors.

src/submodel_macro.jl Outdated Show resolved Hide resolved
true
julia> # Or using some arbitrary expression.
@model submodel_prefix_expr() = a = @returned_quantities prefix=1 + 2 inner()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found

@returned_quantities prefix=1 + 2 inner()

hard and unintuitive to parse. I think

@returned_quantities prefix=(1 + 2) inner()

would be much clearer. Not sure if this a documentation issue, or if we should disallow the former.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a documentation issue IMO, as this is not doing any special parsing but reliying on Julia's expression parsing.

@@ -14,10 +14,18 @@ These statements are rewritten by `@model` as calls of [internal functions](@ref
@model
```

One can nest models and call another model inside the model function with [`@submodel`](@ref).
One can nest models and call another model inside the model function with [`@submodel`](@ref) and [`@returned_quantities`](@ref).

```@docs
@submodel
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the plan to keep @submodel indefinitely, even though @returned_quantities does the same job?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would think we'd remove @submodel at some point. @yebai ?

Copy link
Member

@yebai yebai Oct 31, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, let's depreciate-then-delete @submodel in favour of @returned_quantities

@yebai
Copy link
Member

yebai commented Oct 30, 2024

For returned_quantities/@returned_quantities we still need both, because one is to be used outside of @model, the other inside, right?

generated_quantities allows users to fix model parameter values and/or accept MCMC chain objects.
We can throw an error if users try to pass fixed parameter values or chain objects to @returned_quantities called within a model.

Then, @returned_quantities can match generated_quantities / returned_quantities exactly, thus allowing us to remove the generated_quantities / returned_quantities altogether.

@torfjelde
Copy link
Member Author

Then, @returned_quantities can match generated_quantities / returned_quantities exactly, thus allowing us to remove the generated_quantities / returned_quantities altogether.

Just so we're all on the same page: @returned_quantities and returned_quantities will not match since the former only takes a single argument, while the other takes two, right? If so, then why would we want to raise explicit errors for incorrect arguments provided vs. just letting Julia raise the "not implemented error"?

@torfjelde
Copy link
Member Author

Deprecated generated_quantities in favour of returned_quantities + removed the prefix=... argument for @prefix.

@torfjelde
Copy link
Member Author

torfjelde commented Nov 11, 2024

The best we can do is

@doc """
    @returned_quantities(model::Model, chain::MCMCChains.Chains)

...
""" :(DynamicPPL.@returned_quantities)

I suppose, which gives the false sense of the macro having the capability of multiple dispatch..

@willtebbutt
Copy link
Member

A specific technical point: constant propagation has gotten much better in recent versions of Julia, so I'm not entirely sure that @torfjelde 's point regarding the need to use Val{:prefix} holds. Consider

julia> struct Foo{T} end

julia> @noinline make_foo(T::Symbol) = Foo{T}() # do not inline, rely entirely on constant propagation
make_foo (generic function with 1 method)

julia> @code_warntype (() -> make_foo(:hello))()
MethodInstance for (::var"#71#72")()
  from (::var"#71#72")() @ Main REPL[21]:1
Arguments
  #self#::Core.Const(var"#71#72"())
Body::Foo{:hello}
1%1 = Main.make_foo(:hello)::Core.Const(Foo{:hello}())
└──      return %1

julia> @code_warntype optimize=true (() -> make_foo(:hello))()
MethodInstance for (::var"#3#4")()
  from (::var"#3#4")() @ Main REPL[4]:1
Arguments
  #self#::Core.Const(var"#3#4"())
Body::Foo{:hello}
1return $(QuoteNode(Foo{:hello}()))

Similarly, if I add a method of make_foo which converts Strings to Symbols, constant propagation still works just fine:

julia> @noinline make_foo(x::String) = make_foo(Symbol(x))
make_foo (generic function with 2 methods)

julia> @code_warntype (() -> make_foo("hello"))()
MethodInstance for (::var"#5#6")()
  from (::var"#5#6")() @ Main REPL[6]:1
Arguments
  #self#::Core.Const(var"#5#6"())
Body::Foo{:hello}
1%1 = Main.make_foo("hello")::Core.Const(Foo{:hello}())
└──      return %1


julia> @code_warntype optimize=true (() -> make_foo("hello"))()
MethodInstance for (::var"#7#8")()
  from (::var"#7#8")() @ Main REPL[7]:1
Arguments
  #self#::Core.Const(var"#7#8"())
Body::Foo{:hello}
1return $(QuoteNode(Foo{:hello}()))

Since our implementation of DynamicPPL.prefix just shoves the Symbol straight in the type, provided that users make the prefix a constant Symbol / String, I think everything should be fine.

In short, I think writing prefix(model, :prefix_) will work just fine, which would mean that we could dispense with the @prefix macro entirely, and always just rely on the function.

@torfjelde
Copy link
Member Author

Indeed @willtebbutt seems to be correct that this should work as intended even without requiring @prefix!:)

Buuuut this then means that we're raising this discussion of macros vs. functions again...

@willtebbutt
Copy link
Member

willtebbutt commented Nov 11, 2024

Buuuut this then means that we're raising this discussion of macros vs. functions again...

If a macro is not necessary, then I am entirely of the view that we should use a function. It seems like the@returned_quantities macro really is necessary, because you don't want to have to manually pipe the __context__ and __varinfo__ variables all over the place, but (unless I've missed something) @prefix(model, "prefix_") really does just expand to prefix(model, "prefix_"), which IMHO is just unnecessary complexity.

I'm firmly in favour of dispensing with the @prefix macro!

@torfjelde
Copy link
Member Author

torfjelde commented Nov 11, 2024

Seems @willtebbutt is very much correct here 🎉 The following causes no issues (the model_warntype is from #708 but otherwise this is exacltly this PR)

using Revise, DynamicPPL, Distributions

@model inner0() = x ~ Normal()
@model inner1() = @returned_quantities DynamicPPL.prefix(inner0(), "1")
@model inner2() = @returned_quantities DynamicPPL.prefix(inner1(), "2")
@model inner3() = @returned_quantities DynamicPPL.prefix(inner2(), "3")
@model inner4() = @returned_quantities DynamicPPL.prefix(inner3(), "4")
@model inner5() = @returned_quantities DynamicPPL.prefix(inner4(), "5")
@model inner6() = @returned_quantities DynamicPPL.prefix(inner5(), "6")
@model inner7() = @returned_quantities DynamicPPL.prefix(inner6(), "7")
@model inner8() = @returned_quantities DynamicPPL.prefix(inner7(), "8")

model = inner8()
DynamicPPL.DebugUtils.model_warntype(model, optimize=true) # infers nicely

@mhauru
Copy link
Member

mhauru commented Nov 11, 2024

A small detail, but is there a particular reason to call it returned_quantities rather than returned_values? I guess we originally called it generated_quantities for similarity with Stan, but we are shifting away from that similarity anyway.

@torfjelde
Copy link
Member Author

Summary of discussion from yesterday's meeting.

The discussion can be broken down into two stages:

  1. Should we unify the name of @submodel and generated_quantities? Answer: no.
  2. Because we answered no above, what should the new name of the @submodel and generated_quantities be? Answer: ???

Should we unify the name of @submodel and generated_quantities?

One suggestion discussed previously in this PR was to "unify" @submodel and generated_quantities under a common (@)returned_quantities naming. The reasoning behind this is that they both extract the return values from a model, but one does so within another @model while the other does so conditioned on a chain.

In the end we decided against having a unified name, with the reason becoming clear later, but let's assume for a moment that we do want the same name. In that case, we have a few options:

(1) A straight-forward renaming.

@returned_quantities submodel()    # `@submodel submodel()`
returned_quantities(model, chain)  # `generated_quantities(model, chain)`

(2) Renaming + make generated_quantities a macro

@returned_quantities submodel()     # `@submodel submodel()`
@returned_quantities(model, chain)  # `generated_quantities(model, chain)`

where @returned_quantities(model, chain) simply results in the expression :(DynamicPPL.returned_quantities(model, chain); that is, we export the macro-version only.

(3) Use no macros, but have @model replace calls to returned_quantities(submodel()), i.e.

returned_quantities(submodel())    # `@submodel submodel()`
returned_quantities(model, chain)  # `generated_quantities(model, chain)` 

The argument for (2), and thus against (1), is that it reduces the number of methods that a user needs to use by 1, and for users unfamiliar with macros, it might be confusing to explain that @returned_quantities and returned_quantities are two different "things".

The argument against (2) is that the @returned_quantities(model, chain) very much goes against the principle that we shouldn't mis-use Julia's metaprogramming capabilities when there's no need. Moreover, it adds quite a few annoyances when it comes to stuff like

  1. If you want to change behavior of @returned_quantities(model, chain), then you should in fact not do anything to this macro. Instead, you should overload the internal method DynamicPPL.returned_quantities.
  2. When you add these overloads to DynamicPPL.returned_quantities, e.g. an overload for ::Model, ::Chains, adding a docstring to DynamicPPL.returned_quantities would have to be accompanied with an additional docstring for @returned_quantities, since we don't want to point the user to the private method DynamicPPL.returned_quantites.

Finally, for (3), this is just not possible to implement in a safe way, as at macro-expansion-time we don't know if the returned_quantities(submodel()) is actually DynamicPPL.returned_quantities or if it's user-defined method with the same name, in which case replacing it is incorrect.

Because of all this, we decided that (1) is the way to go but that we should also choose a different name for returned_quantities(model, chain).

What should the new names of @submodel and generated_quantities be?

@submodel

The main suggestion for this is @returned_quantities. Some alternatives of similar flavour:

  • @returned_values
  • @returned

generated_quantities

We started out with returned_quantities, but in the previous main section we decided against this. So then it's a question of what to use instead. Suggestions:

  • extract_returns

@yebai
Copy link
Member

yebai commented Nov 12, 2024

I think we agreed that generated_quantities and @sub_model are semantically identical and should be unified. Also, we decided to rename @submodel to @returned_quantities (some members proposed @returned_values, but I think @generated_quantities is more distinctive and poses less risk of name clash with other packages).

The remaining issue is whether we would like to enforce consistency, which would force one to use @returned_quantities outside models instead of returned_quantities. The overall sentiment is that we should not do this unless a function wouldn't do the trick. I proposed generalising the predict implementation and using that to replace generated_quantities based on the following:

  1. generated/returned_quantities are semantically very close to predict, except for some cosmetic differences (e.g. returned value type and whether to include non-returned variables / VarInfo )
  2. predict is a very good name for this task because we are computing predicted returned values in these cases.

My rule for this PR: (1) replace @submodel with @returned_quantities (2) find a simple way of aliasing generated_quantities(model, chain) with predict(model, chain; returned_quantities=true), then finish #651 and improve predict in future PRs.

@torfjelde
Copy link
Member Author

I think we agreed that generated_quantities and @sub_model are semantically identical and should be unified.

Oh then I definitively misunderstood the sentiment; I thought we landed on (1) but naming returned_quantities(model, chain) something else?

predict is a very good name for this task because we are computing predicted returned values in these cases.

Really, really not a fan of this. My experience with Turing.jl has been that people much prefer explicit methods that does one thing rather than depending on kwargs. It's also an issue that predict returns a Chains while generated_quantities doesn't (and can't in general), and I really don't think a solution is to sometimes return two values and sometimes one, depending on kwarg (this is a big no-no).

then finish #651 and improve predict in future PRs.

At the moment, I don't think there's a clear path to finishing this. It has issues that are unclear how to fix. See #710 and related discussion.

@yebai
Copy link
Member

yebai commented Nov 12, 2024

I'll clarify a few things.

It's also an issue that predict returns a Chains while generated_quantities doesn't (and can't in general), and I really don't think a solution is to sometimes return two values and sometimes one,

Both cases return a single value but possibly of different types, i.e. Vector{NamedTuple}or Chain.

depending on kwarg (this is a big no-no).

The choice of kwarg is relatively minor, and I would be happy if one changed it to positional. The real point is using predict as a user-facing interface.

A more substantial point is using input arguments to determine output types. This is a very standard practice in multi-dispatching, IIUC. In fact, we already do this in several places, using positional arguments with default values, see AbstractMCMC.bundle_samples, and DynamicPPL.rand.

At the moment, I don't think there's a clear path to finishing this. It has issues that are unclear how to fix. See #710 and related discussion.

The point is transferring predict implementation to DynamicPPL (the interface should go to AbstractPPL). The actual mechanism we implement predict, utilising fix or not, is off the point.

@torfjelde
Copy link
Member Author

Both cases return a single value but possibly of different types, i.e. Vector{NamedTuple}or Chain.

My point still stands here I think. A flag (whether it's positional or not) which alters the return-type is generally discouraged.

In fact, we already do this in several places, using positional arguments with default values, see AbstractMCMC.bundle_samples, and DynamicPPL.rand.

Aye, but IMO the key difference there is that the return-type itself is an explicit argument to the function (+ in both those cases, the underlying "behavior" is exactly the same; it's just a matter of type-difference. In contrast predict vs. generated_quantities are doing two different things: one is sampling, i.e. "calling" rand and extracting variables present in ~ statements, while "calls" rand and extracts return)

The actual mechanism we implement predict, utilising fix or not, is off the point.

Gotcha; I misunderstood the purpose of that PR / the comment then 👍

@yebai
Copy link
Member

yebai commented Nov 14, 2024

Let's merge this PR in the next 2-3 days. As I see it, two issues remain here:

  1. the syntax design of returned_quantities when used inside models and
  2. the interface design of returned_quantities when used outside models.

During Monday's meeting, @sunxd3 raised an interesting point regarding (1), which deserved more attention retrospectively: the coloneq syntax introduced #594 could be an excellent solution for (1). Here is how we could use it

# simple case, 
julia> z := returned_quantities(submodel) 

# more general case
#  `coloneq` recursively check for `returned_quantities`
julia> z := returned_quantities(submodel) + 3 

Here, := is already a special syntax, and the model macro captures its LHS and RHS AST. This allows us to implement the same functionality of @submode (renamed to @returned_quantities by this PR). However, avoiding introducing a macro, despite addressing many of our debates, is only a minor advantage. There is a more significant point not getting enough of our attention.

The := syntax, as suggested by the PR description #594, is to allow VarInfo to capture deterministic computation involving RVs as inputs. This is the same with returned variables by submodels, which are deterministic computations of RVs within the submodel. So, relaxing the constraint of := provides a very elegant solution to (1), in the sense that := can capture deterministic computations (of returned variables) from another submodel.

Thoughts?

For issue (2), I'd be happy to keep returned_quantities in this PR and then consider alternatives in the future.

@mhauru
Copy link
Member

mhauru commented Nov 14, 2024

I'm new to the :=, so discount my opinions appropriately, but I can see how it could be a good fit for marking the fact that this is an assignment that creates entries in __varinfo__. It would be especially natural if := would come with automatic prefixing, so that x := y results in @varname(x) being entered into __varinfo__, and x := returned_quantities(submodel) would result in entries like @varname(x.a) and @varname(x.b) where a and b are variables in submodel.

(Note that this would have to fail in some clear and understandable way if submodel returns something that can not be stored in a VarInfo.)

I'm less convinced about this allowing us to drop the @ in @returned_quantities. Would x := returned_quantities(submodel) have to consider returned_quantities to be a special word that is treated differently from any other function name in the RHS of :=? If so, that seems unpleasantly magical to me.

@yebai
Copy link
Member

yebai commented Nov 14, 2024

Would x := returned_quantities(submodel) have to consider returned_quantities to be a special word that is treated differently from any other function name in the RHS of :=? If so, that seems unpleasantly magical to me.

This can be avoided if we make generated_quantities(model) return another generated_quantities_model object, similar to how condition(model, ...) returns a conditioned model.

EDIT: it is possible to make z := SubModel work without generated_quantities(SubModel), but it is very helpful for clarity to use z := generated_quantities(SubModel) in my view. Thus I strongly suggest we treat generated_quantities(model) the same way as condition(model, ...).

@mhauru
Copy link
Member

mhauru commented Nov 14, 2024

I'm not sure I understand. How would generated_quantities(submodel) differ from submodel? If I do the classic things you would do to model, like call evaluate!! or sample on them, how does generated_quantities(submodel) behave?

@yebai
Copy link
Member

yebai commented Nov 14, 2024

Perhaps I shouldn't stress that generated_quantities_model is like a model. It is more of a metaphor.

Here is what it means:

  1. generated_quantities(model)(chain) is equivalent to generated_quantities(model, chain)

  2. generated_quantities(model)(__context__, __varinfo__) is equivalent to generated_quantities(model, __context__, __varinfo__).

Maybe this is slightly unusual at first glance, but I think it is a good idea once we get used to it.

EDIT: Some additional details need to be spelt out during the implementation, so my code snippets above should be taken as illustrations rather than the actual function signature.

@mhauru
Copy link
Member

mhauru commented Nov 14, 2024

Trying to digest this. generated_quantities(model)(__context__, __varinfo__) would also be equivalent to model(__context__, __varinfo__), right?

EDIT: it is possible to make z := SubModel work without generated_quantities(SubModel), but it is very helpful for clarity to use z := generated_quantities(SubModel) in my view. Thus I strongly suggest we treat generated_quantities(model) the same way as condition(model, ...).

It's not clear to me what the clarity is that is gained. What's a possible confusion that using z := generated_quantities(submodel) instead of z := submodel() would avoid?

@yebai
Copy link
Member

yebai commented Nov 14, 2024

It's not clear to me what the clarity is that is gained. What's a possible confusion that using z := generated_quantities(submodel) instead of z := submodel() would avoid?

Semantically, SubModel() / Normal () produces a distributional object. If we assign this object to z, then the z should be a distributional object, too, rather than a sample from it. Here, generated_quantities is to clarify that additional operations are performed on the distributional object to produce a sample from it (or its posterior since information will flow from the outer model to submodels).

@yebai
Copy link
Member

yebai commented Nov 14, 2024

Trying to digest this. generated_quantities(model)(context, varinfo) would also be equivalent to model(context, varinfo), right?

In principle, we could do that (assuming it is consistent with other parts of the codebase).

@torfjelde
Copy link
Member Author

torfjelde commented Nov 14, 2024

Here, := is already a special syntax, and the model macro captures its LHS and RHS AST.

I don't quite understand how := helps here 😕 There are severa things here:

  1. := is (currently) much more stringent than return: it can only handle values that can go in to a AbstractVarInfo. This excludes many interesting objects one might (and I personally have made use of) put into a return so you can capture it with generated_quantities post-infernece, e.g. a full solution of a ODE solve. This is intended, and hence why we didn't just drop generated_quantities when we introduced :=.
  2. With or without :=, and whatever it returned_quantities(model) returns, unless we say "you can only put a returned_quantities(model) on the RHS of a := and nothing else" we're still running into exaclty the same issues as the suggestion of replacing calls to returned_quantities in a @model. Moreover, if we do this, then why would we even require x := returned_quantities(model); why not just do x := model?

@torfjelde
Copy link
Member Author

torfjelde commented Nov 14, 2024

generated_quantities(model)(context, varinfo) would also be equivalent to model(context, varinfo), right?

Also how I understand it, in which case it's just like the current @submodel but (somehow) transforming it using @model instead of explicitly with @submodel x = ...?

@mhauru
Copy link
Member

mhauru commented Nov 14, 2024

Semantically, SubModel() / Normal () produces a distributional object. If we assign this object to z, then the z should be a distributional object, too, rather than a sample from it. Here, generated_quantities is to clarify that additional operations are performed on the distributional object to produce a sample from it (or its posterior since information will flow from the outer model to submodels).

Sorry, I was ambiguous. By z := submodel() I meant a situation where submodel is a Model object, not the function that generates a Model. So

@model inner_model(params)
    blahblah
    return x, y, z
end

inner_model_instance = inner_model(params)

@model outer_model()
    a, b, c := inner_model_instance()
end

EDIT: The @model macro would need to transform the bit within outer_model into something like

rhs = if lhs is a call to a Model object with no arguments
   lhs(__varinfo__, __context__)
else
   lhs
end

However, this is treating objects differently based on their type, rather than by their variable name, which I'm much more okay with.

@torfjelde
Copy link
Member Author

Sorry, I was ambiguous. By z := submodel() I meant a situation where submodel is a Model object, not the function that generates a Model. So

But this would incorrectly call the model unless you somehow captured this fact with the @model macro and converted the call into _evaluate!(...) (which is what @submodel does)?

@yebai
Copy link
Member

yebai commented Nov 14, 2024

By z := submodel() I meant a situation where submodel is a Model object, n

That would be equivalent and I am happy with it. But one would need to write

@model outer_model()
    a, b, c := inner_model(params)() # okay, but one could easily forget including `()` 
    a, b, c := generated_quantities(inner_model(params)) # slightly more verbose, but hard to get wrong. 
end

@torfjelde
Copy link
Member Author

torfjelde commented Nov 14, 2024

That would be equivalent and I am happy with it. But one would need to write

Sorry, but why would generated_quantiites or even () be needed here at all? Would you keep the current functionality of := in addition to introducing submodel capabilities (by using @model to also transform the RHS of :=?)?

@mhauru
Copy link
Member

mhauru commented Nov 14, 2024

But this would incorrectly call the model unless you somehow captured this fact with the @model macro and converted the call into _evaluate!(...) (which is what @submodel does)?

@torfjelde, forgot to comment on this, see the edit I made while you responded. In short: Yes, @model would need to capture this.

@torfjelde
Copy link
Member Author

Yes, @model would need to capture this.

But this comes with a host of issues, no? How are we to recognize that a callable is a model at parse time? Are we then always assuming that whatever is on the RHS of := is a model being called?

@mhauru
Copy link
Member

mhauru commented Nov 14, 2024

How are we to recognize that a callable is a model at parse time?

Would need to be recognized at runtime.

@torfjelde
Copy link
Member Author

Would need to be recognized at runtime.

But how? 😕

@mhauru
Copy link
Member

mhauru commented Nov 14, 2024

Something like "any call in the RHS is converted by @model into a block that at runtime checks if the object being called is a Model, and if yes, adds the arguments __varinfo__ and __context__, otherwise keeps to original call unchanged".

Haven't though through whether this has type stability implications.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

submodel and generated_quantities operations on models
7 participants