brian2 icon indicating copy to clipboard operation
brian2 copied to clipboard

Better syntax for if/else equations

Open mstimberg opened this issue 6 years ago • 7 comments

Prompted by the recent question on the mailing list I thought once more about equations with expressions that are different depending on some condition. Currently, we advise to use something like int(cond)*expr_1 + int(not cond)*expr_2 -- this works in many cases, but is quite ugly (program-y instead of math-y) and does not solve all problems. With this approach, both expressions will be evaluated which not only is slower than necessary, but also makes it impossible to catch things like divisions by 0 without raising an error/warning, or propagate NaN values.

I therefore think it would be good to have an explicit syntax for this. I basically see three options:

Option 1 Something similar to what I proposed in #760 , i.e. have a flag for alternative definitions of expressions. This could look like this:

eqs = '''
alpha = (v-v_alpha)/(1-exp(-(v-v_alpha)/c_alpha)) : Hz (if: abs(v-v_alpha)>=1e-6*mV)
alpha = c_alpha+(v-v_alpha)/2 : Hz                     (if: abs(v-v_alpha)<1e-6*mV)
'''

The problem with this syntax is that 1) we'd have to somehow check whether the two conditions are complementary and 2) that this would not work well with our current approach of using subexpressions in equations (we just replace them and let sympy handle it from there). The first problem could be solved by restricting ourselves to two condition and using "otherwise" for the second. For the second problem, we'd probably have to internally use a definition as in the function approach (Option 2).

Option 2 Provide a where function similar in spirit to numpy, i.e. where(cond, expr_1, expr_2). This would look like this:

eqs = '''
alpha = where(abs(v-v_alpha)>=1e-6*mV, (v-v_alpha)/(1-exp(-(v-v_alpha)/c_alpha)), c_alpha+(v-v_alpha)/2) : Hz
'''

Implementation-wise this would be straightforward (we could already implement it without any changes in Brian itself, just by using a user-provided function, I think), but I'm not entirely convinced that it is very readable. We'd distance ourselves a bit from a purely mathematical notation.

Option 3 Support Python's ternary expr_1 if cond else expr_2 notation. This would look like this:

eqs = '''
alpha = (v-v_alpha)/(1-exp(-(v-v_alpha)/c_alpha)) if abs(v-v_alpha)>=1e-6*mV else c_alpha+(v-v_alpha)/2) : Hz
'''

Implementation-wise this would be a bit trickier again, but (especially if we do a codegen rewrite) should be not too difficult either, given that we use Python's AST parsing in the first place. This would be consistent with our use of Python syntax elsewhere (e.g. ** for power), but then again this would look more like Python code than mathematical notation (which already leads to plenty of confusion...).

I can think of other options, but they'd add even more specific syntax. Or is there anything I overlooked?

mstimberg avatar Nov 16 '18 17:11 mstimberg

I'd definitely rule out option 2 as too much tied to numpy and quite unreadable. I like aspects of both 1 and 3, but both have their issues. I suspect that most people, most of the time would like something like option 1. On the other hand, I think once we start allowing ifs in code blocks (reset statements) we're going to want a Python-like syntax so maybe it makes sense to anticipate that and provide it here.

Another consideration is that very often people are going to want to provide more than two conditions, and that looks much nicer option 1 style than option 3 style.

Last consideration, for option 1, I don't like the repeated information (definition of name and unit). How about something like this (probably can improve on it, but just to give the idea):

alpha = conditional(
    if abs(v-v_alpha)>=1e-6*mV:  (v-v_alpha)/(1-exp(-(v-v_alpha)/c_alpha))
    else:                         c_alpha+(v-v_alpha)/2
    # could also have else if
    ) : Hz

and you could allow formatting like

alpha = conditional(
    if abs(v-v_alpha)>=1e-6*mV:
	    (v-v_alpha)/(1-exp(-(v-v_alpha)/c_alpha))
    else:
	    c_alpha+(v-v_alpha)/2
    ) : Hz

thesamovar avatar Nov 19 '18 12:11 thesamovar

I agree to the point about not repeating info. I'm still a bit torn between having something Python-like and something that looks as mathematical as possible. A quite drastic solution that follows the latter approach would be to introduce special syntax making use of special characters that are not used otherwise. Say, something like this:

'''
alpha = {
        (v-v_alpha)/(1-exp(-(v-v_alpha)/c_alpha)) | if abs(v-v_alpha)>=1e-6*mV
        c_alpha+(v-v_alpha)/2                     | otherwise
        } : Hz
'''

FWIW, Annarchy went for something similar to your suggestion (and additionally a function that implements option 2), but for a straight if without any conditional(...): https://annarchy.readthedocs.io/en/stable/manual/Parser.html#conditional-statements

From the documentation you already get the feeling that this is a bit tricky to parse correctly, though.

Oh, and I also agree that we should keep code blocks in mind, even though I don't think this necessarily implies to go for Python-syntax. E.g. if we decide to, say, go for | as a "filter/condition" operator, then this could be used in code blocks as well:

reset = '''
v = 0  | if v<0
'''

mstimberg avatar Nov 19 '18 14:11 mstimberg

I quite like that { ... | if ... } notation. One downside of that, in both that case and the code block case, is that it's potentially confusing what it means. For example, if

x = {
  1 | if y<5
  2 | if y<10
  3 | otherwise
  }

My interpretation would be that x=2 only if 5<=y<10 but someone else might expect to read this as equivalent to

if y<5:
  x = 1
if y<10:
  x = 2
else:
  x = 3

In that case, y=0 would give x=2 not x=1, for example. You could make it more clear what it means by having else if but that reads grammatically incorrectly in the value | else if condition structure.

It also makes it impossible to nest if statements, which might be useful if you have a condition on one variable and a condition on another variable, for example.

We could also go the route of having multiple types of syntax.

How about this? We combine this with some other changes we've been thinking about (see https://github.com/brian-team/brian2/issues/111 and https://github.com/brian-team/brian2/issues/902 and one other issue I can't find now on automatically generating function implementations from Python code) and allow for a function definition system. Now we have a new syntax for if/else/then that can be only used inside function definitions or code blocks that follow Python syntax, and users have to write a function if they want to use this in mathematical equations. So, the example above could be:

eqs = '''
    def alpha_func(v):
        if abs(v-v_alpha)>=1e-6*mV:
            return (v-v_alpha)/(1-exp(-(v-v_alpha)/c_alpha))
        else:
            return c_alpha+(v-v_alpha)/2
            
    ... (other equations here)
    alpha = alpha_func(v) : Hz
    '''

or (another option)

@autofunc
def alpha_func(v, v_alpha, c_alpha):
    if abs(v-v_alpha)>=1e-6*mV:
        return (v-v_alpha)/(1-exp(-(v-v_alpha)/c_alpha))
    else:
        return c_alpha+(v-v_alpha)/2
eqs = '''
alpha = alpha_func(v, v_alpha, c_alpha)
'''

In addition to that, we have a mathematical syntax for "cases" something like your { .. | if ..} syntax above. I was just thinking that in this case we can handle overlaps in the definition at runtime. If the number of case conditions that hold is not equal to 1 then it raises an error (e.g. if they don't have an otherwise when they need to, or they have two conditions that both hold).

thesamovar avatar Nov 19 '18 15:11 thesamovar

I wouldn't put function definitions into equations, I think this would lead users even more to think that they can write arbitrary Python code there (in particular use [...] indexing, etc.). For the same reason, I'm also not fully convinced of using something like proposed in #902 for synaptic connections. By using multiple Synapses.connect statements you can already implement quite complicated rules, and by additionally having an option to delete synapses, we'd increase the expressiveness even further. And then, all this matters only if you have to calculate your connections in standalone code instead of writing things down in Python.

The @autofunc (or maybe something closer to the @implementation name we already have, e.g. @autoimplementation?) approach could be nice, though. Users could simply try it and see whether it works for their case. Still, if possible I'd prefer to keep it possible to implement most models just by strings, without having to resort to functions.

I think the { ... | if ..} syntax could work for this (even though I have mixed feelings about introducing curly braces :smirk: ). We should be careful to not overthink it, though. For example, I think it would be fine to just have one if condition and one (optional) otherwise, for a start. You can always extend this and have nesting by using multiple variables, e.g.:

'''
alpha = { a   | if x < 100
          ... | otherwise } : Hz
a = { ... | if x < 50
      ... | otherwise }
'''

Alternatively or additionally, we could also have a special syntax for range checks, which are one main use case. E.g. something like

'''
alpha = { .... | if v in ]-inf*mV, 0*mV]
          .... | if v in ]0*mV, 50*mV]
          .... | otherwise }
'''

By limiting the ranges to constants we'd be able to check for overlaps in a straightforward way.

mstimberg avatar Nov 19 '18 15:11 mstimberg

We can definitely agree that the ideal should be that writing code-like things rather than maths-like things should be the exception rather than the rule.

I don't like the idea of having only if/otherwise and using temp variables to make more complicated ones. Seems ugly, and I think it might be a reasonably common case.

I don't mind the curly brackets for maths things because it looks like the amsmath latex cases environment (which has a big left curly bracket to start it).

To make it grammatically coherent and resolve the ambiguity about order, how about

alpha = { ... if v<0 else
          ... if v<50 else
          ... }

The | seems unnecessary, the else makes it clear the flow, and with that the otherwise becomes unnecessary.

thesamovar avatar Nov 19 '18 16:11 thesamovar

I have to think a bit more about this, but I just noticed that your latest proposal actually is actually option 3 (Python's ternary operator) with added curly braces and line-breaks :)

mstimberg avatar Nov 19 '18 17:11 mstimberg

Ooh! I hadn't realised you could chain ternary operators like that! In that case, my vote is now for that without the curly brackets (which are presumably entirely unnecessary?), and we make sure to always format them like the above in our examples and docs.

thesamovar avatar Nov 19 '18 17:11 thesamovar