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

Handle Vensim Subscripting #21

Closed
JamesPHoughton opened this issue May 21, 2015 · 34 comments
Closed

Handle Vensim Subscripting #21

JamesPHoughton opened this issue May 21, 2015 · 34 comments

Comments

@JamesPHoughton
Copy link
Collaborator

A number of the most interesting models utilize subscripts, and so if PySD is to be useful in their analysis, it should support this feature.

What we describe as 'subscripts' encompasses a number of different features:

We should start with only a subset of these features. (An outstanding question is if we want to ever support all of them. A good goal would be to match the functionality of the XMILE schema.)

Subscripts have different behaviors in a number of different contexts:

@JamesPHoughton
Copy link
Collaborator Author

A few thoughts:

  • We want to be able to handle arbitrarily sized, arbitrarily dimensioned subscripts.
  • They probably should be stored in some form of array structure, with fast lookups.
  • Not everything that participates in a subscript expression needs to be expanded - for instance, an intermediate auxiliary variable doesn't need n copies of itself, one for each subscript combination, it just needs to be able to access things in the right places.
  • Stocks clearly need to be expanded
  • Constant values could either be expanded or have a single function with an internal lookup table.
  • When should we expand the stocks? At translate time, or at import, or at setup?
  • If we're going to use arrays for stocks in subscripts, it might make sense to use them for delays as well. This might give us an opportunity to practice dealing with arrayed stocks in the integration without having to write any new parsing.
  • Speaking of parsing, this is going to mean a pretty big change to the parsing grammar. If we want to re-evaluate the parsing strategy, it might make sense to do so before implementing subscripts.

@JamesPHoughton
Copy link
Collaborator Author

For simplicity it probably makes sense to implement subscript notation using python decorator syntax, to preserve the simplicity of the implemented python function. Something like:

    @subscript(sub1, sub2)
    def func(self):
         return self.otherfunc()

We may not even need to have a fixed set of parameters in the decorator call, but broadcast whatever is passed in...

    @subscript
    def func(self):
         return self.otherfunc()

We'd need to have some clever error checking, though.

We need the decorator function to be able to modify calls to other functions from within the decorated functions. We may be able to use something akin to numpy's vectorize capability...

@JamesPHoughton
Copy link
Collaborator Author

Next steps:

  1. Create models exercising each subscript feature we're interested in adding, and add them to the test suite.
    • One-dimensional, non-interacting array. (3 teacups?)
    • Multi-dimensional, non-interacting array. (9 teacups divided into 3 different rooms?)
    • Subscript summary functions. (Average temp of 3 teacups?)
    • Subscript subranging
    • Multiple equations
    • Exceptions
    • Subscript mapping
    • Numeric Ranges
  2. Prototype various python syntax for handling subscript arrays in each relevant context:
    • Stocks/state dictionary
    • Constants/Exogenous functions
    • Auxiliary functions
  3. Modify builder template class to handle changes in python model syntax
  4. Add to builder functions the ability to construct python subscript grammar
  5. Integrate vensim parsing.

@JamesPHoughton
Copy link
Collaborator Author

First step is probably to write test cases for each of the subscript features. I'm moving over to the joint test suite, and will add these tests here: https://github.com/SDXorg/test-models

@JamesPHoughton
Copy link
Collaborator Author

Syntax should:

  • Make sense to a human
  • Require no changes to the existing syntax for models that dont have subscripts
  • Be relatively low-overhead (ie, not take too long to compute)
  • Look forward to integration with numba/theano/cython type enhancements

@JamesPHoughton
Copy link
Collaborator Author

Other complexities:

  • subscripted lookups
  • subscripted delays/smooth functions

@pbreach
Copy link
Contributor

pbreach commented Sep 26, 2015

For this it would make some sense to have a labelled ndarray, something like a DataArray as in xray.

Subranging, subscript mapping, broadcasting, and summary functions would practically come out of the box and have a more pandas-like syntax. What do you think?

@JamesPHoughton
Copy link
Collaborator Author

Patrick - I think you might be on the right track.

Looking for now just at how we represent the subscripted state, it seems like we have several options for representing the subscript structure:

Flat Dictionary

We could use the flat state dictionary the way it currently exists, and add suffixes to the variable names to indicate a position in the subscript structure.

#1d subscript on stock 2
state = {'stock1':3, 'stock2_sub1':1, 'stock2_sub2':4, 'stock2_sub3':1}  

#2d subscript on stock 2
state = {'stock1':3, 
         'stock2_suba1_subb1':1, 'stock2_suba2_subb1':4, 'stock2_suba3_subb1':1,
         'stock2_suba1_subb2':5, 'stock2_suba2_subb2':9, 'stock2_suba3_subb2':2 
        }  

This is nice because it doesn't require any new unpacking/repacking machinery, but could get very unweildy when the number of subscripts grows.

Nested Dictionaries

We could nest subscript values within dictionaries within the state dictionary.

#1d subscript on stock 2
state = {'stock1':3, 'stock2':{'sub1':1, 'sub2':4, 'sub3':1}}  

#2d subscript on stock 2
state = {'stock1':3, 
         'stock2': {suba1:{'subb1':1, 'subb2':4}, 
                    suba2:{'subb1':1, 'subb2':5}, 
                    suba3:{'subb1':9, 'subb2':2}
                   }
        }  

This has several advantages:

  • the values are easy to access one at a time: state['stock2']['suba2']['subb2'] >> 5
  • there is no overhead of bringing in additional packages

Unnamed ndarrays

If we are ok storing the names of the subscript elements in a separate structure, we can
just use regular multidimensional arrays (standard python lists, or full numpy vectors).

#1d subscript on stock 2
subs = ['sub1', 'sub2', 'sub3']
state = {'stock1':3, 'stock2':[1, 4, 1]}  

#2d subscript on stock 2
suba = ['suba1', 'suba2', 'suba3']
subb = ['subb1', 'subb2']
state = {'stock1':3, 'stock2': [[1,4,1][5,9,2]]}  

Accessing values gets rather complicated in this arrangement:
state['stock2'][suba.index('suba2')][subb.index('subb2')] >> 9

Named ndarrays

The nicest solution from an implementation perspective is probably a type of named array (using either pandas or xray or some other type of container.

#1d subscript on stock 2
subs = ['sub1', 'sub2', 'sub3']
state = {'stock1':3, 'stock2': pd.Series(data=[1, 4, 1], index=['sub1', 'sub2', 'sub3']})  

#2d subscript on stock 2
suba = ['suba1', 'suba2', 'suba3']
subb = ['subb1', 'subb2']
state = {'stock1':3, 'stock2': pd.DataFrame(data=[[1,4,1][5,9,2]], 
                                            index=['suba1', 'suba2', 'suba3']}  
                                            columns=['subb1', 'subb2'])
        }

This would be easy to access, but might make the integration slow, because of the extra overhead. Additionally, we want to make sure we don't add too many more dependencies.

The way to do this is probably to test some of these in a sandbox environment, and do speed tests...

@JamesPHoughton
Copy link
Collaborator Author

The way we construct arrays in the state vector should also drive how constants (and in some cases, equations) are subscripted as well...
For the nested dictionary case:

def constant(sub):
    values = {'a':3, 'b':4, 'c':5}
    return values[sub]

For the named array case:

def constant(sub):
    values = pd.DataFrame(data=[[1,4,1][5,9,2]], 
                          index=['suba1', 'suba2', 'suba3']}  
                          columns=['subb1', 'subb2']
    return values[sub]

etc.

@JamesPHoughton
Copy link
Collaborator Author

Here's another issue - in function calls, we can either enumerate the specific subscript names as separate parameters or lump them all into an array:

def flow(subs1, subs2, subs3):
    return constant(subs1, subs2, subs3) * stock(subs1, subs2, subs3)

vs.

def flow(subscripts): #subscripts is a list of 3 different subscript dimensions
    return constant(subscripts) * stock(subscripts)

The second approach is cleaner, especially for long equations, but less explicit.

We could alternately try some sort of hybrid, with syntax akin to what is described in this stack exchange post. Then we could be explicit in the function definition, but implicit in the calls within the function.

@pbreach
Copy link
Contributor

pbreach commented Sep 29, 2015

Wow, quite a few options here!

I agree, sandboxing some of these seems the way to go. I'm not sure how scalable the python nested dict or list of lists (of lists possibly) would be... but the overhead of using pandas or xray is a really good point especially if they need to be instantiated every call. Maybe it's possible to store these as attributes instead.. but that would be a huge change up I think.

The unnamed ndarray option seems to be more flexible than named and can still broadcast and aggregate along arbitrary dimensions / ranges.

Hybrid approach for function calls / definitions seems like a good idea. Then we can see the dims in the definition of something that is subscripted.

Once we get tests together then we can see how these work out.

@JamesPHoughton
Copy link
Collaborator Author

We might be able to get away with not instantiating variables every call, but setting them up once and modifying their values. For the constants, we could define them as attributes of the function:

def constant(sub):
    return constant.values[sub]
constant.values = {'sub1':3, 'sub2':4}

constant('sub1')

We could avoid re-instantiating variables within the state dictionary just by coding them properly.

That doesn't totally avoid the overhead however.

If we decide to go with the unnamed array, instead of doing an 'index' type lookup, we could define the index externally. Something else to check for speed.

#index lookup
state['stock2'][suba.index('suba2')][subb.index('subb2')]

#external reference 
suba_index = {'suba1':0, 'suba2':1, 'suba3':2}
subb_index = {subb1':0, 'subb2':1}
state['stock2'][suba_index['suba2']][sinn_index['subb2]]

I'm not sure if its as elegant, but it may be faster...

@JamesPHoughton
Copy link
Collaborator Author

I made a super-basic demo of how we could implement using unnamed arrays, in the sandbox. Basically I pulled out the functional pieces of pysd that are needed for the demo and put them in their own file, separate of the project. It's written for clarity, not speed, so we should refactor before comparing with other options on any speed tests, specifically the components associated with packing and unpacking the state vector.

The biggest question that this brought up was whether we should broadcast the subscript function calls in the model file, or in the template class? Right now I have it in the dstock function. It may be good to have it here, because some stocks won't be broadcast to all subscripts, and we need a way to work that out?

@pbreach - how would you feel about modifying this to make a subscript demo using xray?

@pbreach
Copy link
Contributor

pbreach commented Sep 29, 2015

@JamesPHoughton Sure thing, Just finished adding, but it is extremely slow. I tried to profile and seems like indexing a DataArray many times is extremely inefficient for this purpose.

It would be nice if it weren't so slow seeing as supporting the rest of the subscripting functionality would be trivial to implement as long as the translator can pick up on it... Maybe I missed something.

@JamesPHoughton
Copy link
Collaborator Author

How much overhead are you getting? Profiling the current unnamed arrays version, creating the array structure in constant is a significant portion of the bottleneck:

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
     4362    0.013    0.000    0.037    0.000 <ipython-input-3-cc7f4a338a85>:80(flow)
     5089    0.009    0.000    0.009    0.000 {numpy.core.multiarray.array}
     4362    0.008    0.000    0.018    0.000 <ipython-input-3-cc7f4a338a85>:76(constant)
  ...

If I pull that out (make it an attribute of the function, as in this gist) then we're back to the case where the model functions themselves are the biggest contributor.

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
     4302    0.012    0.000    0.019    0.000 <ipython-input-17-eec3a202e7d0>:82(flow)
     4302    0.004    0.000    0.004    0.000 <ipython-input-17-eec3a202e7d0>:78(constant)
      717    0.003    0.000    0.022    0.000 <ipython-input-17-eec3a202e7d0>:75(dstock_dt)
     4302    0.003    0.000    0.003    0.000 <ipython-input-17-eec3a202e7d0>:69(stock)
   ...

I'm also rebuilding the arrays when I re-pack the state dictionary in set_state. With the numpy array, it doesn't seem to be a big part of the bottleneck, (maybe only because its called fewer times?) but you might have more overhead there, and refactoring to modify instead of recreate the structure might help...

@pbreach
Copy link
Contributor

pbreach commented Sep 30, 2015

OK now I've added in the example. The kind of overhead I am getting is ~8s to integrate as opposed to ~0.016s with the unnamed example above it. I tried taking out DataArray creation from the constant by just returning a value and still takes about 5 seconds.

Check it out and let me know what you think. Personally I think the unnamed arrays are looking pretty promising based on the example and results.

@JamesPHoughton
Copy link
Collaborator Author

Interesting! I pulled out the array instantiation in constant, and that seems to save about half of the delay:

    def constant(self, suba, subb):
        return self.constant.values.loc[suba, subb].values
    constant.values = DataArray([[1,2],[3,4],[5,6]], dim_dict)

The other half seems to be some sort of repeating call cycle in xray ( lookup_positions -> __getitem__ -> _remap_key -> genexpr -> lookup_positions) I wonder what ends up breaking it?

Possibly a bug in xray?

@pbreach
Copy link
Contributor

pbreach commented Oct 1, 2015

Found the problem! The regression happens when setting values in set_state using self.state[key].loc[:,:] = array. Turns out that .loc returns a subsetted DataArray rather than the underlying numpy array. To set values we needed to use self.state[key].loc[:,:].values = array. This is slightly different from pandas so it threw me off a little bit. Here is the result from the unnamed array:

38733 function calls in 0.083 seconds

   Ordered by: internal time

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
     4302    0.026    0.000    0.066    0.000 <ipython-input-10-cc7f4a338a85>:80(flow)
     5019    0.016    0.000    0.016    0.000 {numpy.core.multiarray.array}
     4302    0.013    0.000    0.031    0.000 <ipython-input-10-cc7f4a338a85>:76(constant)
     4302    0.007    0.000    0.009    0.000 <ipython-input-10-cc7f4a338a85>:67(stock)
      717    0.005    0.000    0.071    0.000 <ipython-input-10-cc7f4a338a85>:73(dstock_dt)
...

and from using xray:

120300 function calls (119850 primitive calls) in 0.157 seconds

   Ordered by: internal time

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
      450    0.007    0.000    0.016    0.000 dataset.py:542(_copy_listed)
      450    0.007    0.000    0.024    0.000 index.py:109(__new__)
      999    0.007    0.000    0.014    0.000 collections.py:38(__init__)
    15446    0.005    0.000    0.007    0.000 {isinstance}
     1603    0.005    0.000    0.005    0.000 {numpy.core.multiarray.array}
      450    0.004    0.000    0.004    0.000 {pandas.lib.infer_dtype}
     6714    0.004    0.000    0.004    0.000 {hasattr}
     1323    0.004    0.000    0.008    0.000 _abcoll.py:545(update)
     2169    0.003    0.000    0.003    0.000 collections.py:59(__setitem__)
      117    0.003    0.000    0.033    0.000 dataset.py:934(isel)
      684    0.003    0.000    0.012    0.000 variable.py:70(_as_compatible_data)
      450    0.003    0.000    0.024    0.000 dataset.py:604(__getitem__)
...

So you can see there is a bit of overhead in using xray (takes ~2X longer in this example). My guess is that more equations will lead to longer run times using xray compared to the unnamed arrays. There would probably be a performance benefit once the arrays get really big... But I think it's unlikely that we will be dealing with subscripted arrays that don't fit in memory for example.

The only advantage would be easier to implement subscripting functionality but I think it would be not too much harder with the unnamed arrays.

@JamesPHoughton
Copy link
Collaborator Author

Thats the same order of magnitude, which is helpful. It might make sense to sandbox out some of the other subscript operations, (maybe just the aggregation functions?) before we commit to one path or the other.

It would also make sense to sandbox a numba/cython/theano type speedup operation, to see how easy it is to do in each setting. I'll have a look at that.

I also like the idea of keeping all of the array name values in a single nested dictionary, as opposed to spreading them around.

@pbreach
Copy link
Contributor

pbreach commented Oct 1, 2015

Here are the updated timings (I left DataArray creation in the constant function by mistake).

unnamed arrays:

34431 function calls in 0.053 seconds

   Ordered by: internal time

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
     4302    0.021    0.000    0.038    0.000 <ipython-input-30-17794e12d635>:79(flow)
     4302    0.007    0.000    0.010    0.000 <ipython-input-30-17794e12d635>:76(constant)
     4302    0.006    0.000    0.007    0.000 <ipython-input-30-17794e12d635>:67(stock)
      717    0.005    0.000    0.043    0.000 <ipython-input-30-17794e12d635>:73(dstock_dt)
...

and from using xray:

66624 function calls (66390 primitive calls) in 0.068 seconds

   Ordered by: internal time

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
      234    0.003    0.000    0.010    0.000 index.py:109(__new__)
      117    0.003    0.000    0.028    0.000 dataset.py:934(isel)
      234    0.003    0.000    0.007    0.000 dataset.py:542(_copy_listed)
     9506    0.002    0.000    0.002    0.000 {isinstance}
      351    0.002    0.000    0.003    0.000 collections.py:38(__init__)
     3582    0.002    0.000    0.002    0.000 {hasattr}
...

They're slightly faster because I'm on my office computer. Looks pretty similar now, but you're right it would be ideal to test out the other subscripting functionality. It will also be interesting to see how we can fit this into numba/cython/theano.

@JamesPHoughton
Copy link
Collaborator Author

Had some fun today sandboxing what would happen if instead of:
a) returning single values from function calls, and sweeping over the array of subscript values, calling the function once per subscript value combo; we
b) return full arrays from each function call, and only call the functions once. Then we use array mathematics to combine everything.

In the sandbox example:

    def stock(self):
        return self.state['stock']

    def stock_init(self):
        return np.array([[1,1],[1,1],[1,1]])

    def dstock_dt(self):
        return self.flow()

    def constant(self):
        return np.array([[1,2],[3,4],[5,6]])

    def flow(self):
        return self.constant() * self.stock()

The code actually turns out to be simpler, and faster. (9.09 ms per loop vs. 16.6 ms per loop)

However, I'm not sure if it'll work for some of the more complex subscripting constructs. I think we should make the sandbox examples more complex - try the aggregation or range functions, and see if that influences our decision making...

@JamesPHoughton
Copy link
Collaborator Author

We might be able to do the same sort of thing using xray...

@pbreach
Copy link
Contributor

pbreach commented Oct 19, 2015

I added the equivalent xray example of the array-based operation and fixed option 2 in #43. It doesn't seem too slow, but I think things will start to get interesting once we move onto some more complicated cases.

Would it make sense to come up with a simple example model using some of these aggregation functions to test? Maybe something like the teacup example, except with multiple teacups. The total cooling of the teacups could contribute to temperature of the room... Or maybe something simpler?

@BaharEs
Copy link

BaharEs commented Oct 19, 2015

I can send you a couple of Vensim models (simple and moderate), if you want
to play with them and see the result. Just let me know...

@JamesPHoughton
Copy link
Collaborator Author

@pbreach nice! Where you you think the extra overhead comes from? I think doing some aggregation tests would be a great idea - I'm not sure they even have to be as complex as the teacup example. What about something like this test.

@BaharEs - It would be great to see your models - how would you feel about including them in a set of sample models in https://github.com/SDXorg/test-models? Alternately, if you think they're simple enough to be used as unit-test type models, we could include them directly with the test suite...

@JamesPHoughton
Copy link
Collaborator Author

I tried out some aggregation functions (sum, max) in the sandbox. Xray definitely wins for being explicit, and the matrix style operations are a good boost in speed. If we're really worrying about speed at this point though, perhaps we should look at how we can integrate with python speedups referenced in #12.

@JamesPHoughton
Copy link
Collaborator Author

I think at this point we should go with option 4 - the xray case. Its more explicit, and I think will be easier to develop/debug. If we get to the point where we need the extra speed, and we can't get it out of xray, we'll go back to option 3 - they are fairly similar. I've created a branch to do this dev in, and added the functionality around packing/unpacking. See:
https://github.com/JamesPHoughton/pysd/blob/subscript-dev/pysd/builder/componentClass.py#L86-L125

The tests pass as well as they did before (some bugs in other places). I think if we hand-code some models, we should be able to test the subscript functionality. Then we can work on the add_stock, add_flaux parts of the builder.

@pbreach
Copy link
Contributor

pbreach commented Oct 27, 2015

@JamesPHoughton Well, I tried the same thing using pandas dataframes and it seems to be even slower (~3-4x) than xray. My guess is maybe the overhead is in the construction of the DataArray and also lining up labels based on the coordinate axes during arithmetic (really I have no idea).

I like the aggregation test you put together. I'm not sure what the status is on incorporating subscript parsing (I see you've starting on bringing in ANTLR which is great!). But some more hand-coded models would be good. If I have time I might be able to put some together similar to how you did in the sandbox.

For speed-ups I think xray will be great for working with large data (still has to fit on one machine). It supports using dask as a back-end for doing array ops on disk in chunks with multiprocessing. For fast computations we would still be able to get the underlying numpy array and send it through a cython or numba function as well?

@JamesPHoughton
Copy link
Collaborator Author

Floating questions:

  • How do we modify constants that are subscripted? Do we replace the whole function, or elements of an array in the existing function.
  • How do we display the results of subscripted calculations to the user at the end of the computation?
  • How do we handle subscripted functions with array-based calculations?

@JamesPHoughton
Copy link
Collaborator Author

Use numpy where for if-then-else in array: http://docs.scipy.org/doc/numpy-1.10.1/reference/generated/numpy.where.html

@JamesPHoughton
Copy link
Collaborator Author

Just to make sure things don't get lost as we play more in the sandbox, here are the 4 versions mentioned above. All have the same dependencies:

from __future__ import division 
import numpy as np 
from pysd import functions
from scipy.integrate import odeint
import itertools

Option 1: Calling through unnamed arrays, one function evaluation per array element.

class MinModel(object):
    ##########  boilerplate stuff from the existing pysd #########
    def __init__(self):
        self._stocknames = [name[:-5] for name in dir(self) if name[-5:] == '_init']
        self._stocknames.sort() #inplace
        self._dfuncs = [getattr(self, 'd%s_dt'%name) for name in self._stocknames]
        self.state = dict(zip(self._stocknames, [None]*len(self._stocknames)))
        self.reset_state()
        self.functions = functions.Functions(self)

    def reset_state(self):
        """Sets the model state to the state described in the model file. """
        self.t = self.initial_time() #set the initial time
        retry_flag = False
        for key in self.state.keys():
            try:
                self.state[key] = eval('self.'+key+'_init()') #set the initial state
            except TypeError:
                retry_flag = True
        if retry_flag:
            self.reset_state() #potential for infinite loop!

    ########### Stuff we have to modify to make subscripts work #########
    def d_dt(self, state_vector, t):
        """The primary purpose of this function is to interact with the integrator.
        It takes a state vector, sets the state of the system based on that vector,
        and returns a derivative of the state vector
        """        
        self.set_state(state_vector)
        self.t = t

        derivative_vector = []
        for func in self._dfuncs:
            derivative_vector += list(func())

        return derivative_vector

    def set_state(self, state_vector):
        i = 0
        for key in self._stocknames:
            if isinstance(self.state[key], np.ndarray):
                size = self.state[key].size
                elements = state_vector[i:i+size]
                shape = self.state[key].shape
                self.state[key] = np.array(elements).reshape(shape)
                i += size
            else:
                self.state[key] = state_vector[i]
                i += 1


    def get_state(self):
        #if we keep this, we should make it fully a list comprehension
        state_vector = []
        for item in [self.state[key] for key in self._stocknames]:
            if isinstance(item, np.ndarray):
                state_vector += list(item.flatten())
            else:
                state_vector += list(item)
        return state_vector


    ######### model specific components (that go in the model file)
    suba_list = ['suba1', 'suba2', 'suba3']
    subb_list = ['suba2', 'subb2']
    suba_index = dict(zip(suba_list, range(len(suba_list))))
    subb_index = dict(zip(subb_list, range(len(subb_list))))

    def stock(self, suba, subb):
        return self.state['stock'][self.suba_index[suba]][self.subb_index[subb]] 

    def stock_init(self):
        return np.array([[1,1],[1,1],[1,1]])

    def dstock_dt(self):
        return [self.flow(suba, subb) for suba, subb in itertools.product(self.suba_list, self.subb_list)]

    def constant(self, suba, subb):
        return self.constant.values[self.suba_index[suba]][self.subb_index[subb]]
    constant.values = np.array([[1,2],[3,4],[5,6]])

    def flow(self, suba, subb):
        return self.constant(suba, subb) * self.stock(suba, subb)

    def initial_time(self):
        return 0

a = MinModel()
----
%%timeit
a.reset_state()
odeint(a.d_dt, a.get_state(), range(10))

100 loops, best of 3: 16.4 ms per loop

Option 2: Using xray, one function call per array element

from xray import DataArray

class xMinModel(object):
    ##########  boilerplate stuff from the existing pysd #########
    def __init__(self):
        self._stocknames = [name[:-5] for name in dir(self) if name[-5:] == '_init']
        self._stocknames.sort() #inplace
        self._dfuncs = [getattr(self, 'd%s_dt'%name) for name in self._stocknames]
        self.state = dict(zip(self._stocknames, [None]*len(self._stocknames)))
        self.reset_state()
        self.functions = functions.Functions(self)

    def reset_state(self):
        """Sets the model state to the state described in the model file. """
        self.t = self.initial_time() #set the initial time
        retry_flag = False
        for key in self.state.keys():
            try:
                self.state[key] = eval('self.'+key+'_init()') #set the initial state
            except TypeError:
                retry_flag = True
        if retry_flag:
            self.reset_state() #potential for infinite loop!

    ########### Stuff we have to modify to make subscripts work #########
    def d_dt(self, state_vector, t):
        """The primary purpose of this function is to interact with the integrator.
        It takes a state vector, sets the state of the system based on that vector,
        and returns a derivative of the state vector
        """        
        self.set_state(state_vector)
        self.t = t

        derivative_vector = []
        for func in self._dfuncs:
            derivative_vector += list(func())

        return derivative_vector

    def set_state(self, state_vector):
        i = 0
        for key in self._stocknames:
            if isinstance(self.state[key], DataArray):
                shape = self.state[key].shape
                size = self.state[key].size
                self.state[key].loc[:,:].values = np.array(state_vector[i:i+size]).reshape(shape)
                i += size
            else:
                self.state[key] = state_vector[i]
                i += 1

    def get_state(self):
        #if we keep this, we should make it fully a list comprehension
        state_vector = []
        for item in [self.state[key] for key in self._stocknames]:
            if isinstance(item, DataArray):
                state_vector += list(item.values.flatten())
            else:
                state_vector += list(item)
        return state_vector


    ######### model specific components (that go in the model file)
    dim_dict = {'suba': ['subb1', 'subb2'],
                'subb': ['suba1', 'suba2', 'suba3']}

    def stock(self, suba, subb):
        return self.state['stock'].loc[suba, subb].values

    def stock_init(self):
        return DataArray([[1,1],[1,1],[1,1]], self.dim_dict)

    def dstock_dt(self):
        return [self.flow(suba, subb) for suba, subb in itertools.product(*self.dim_dict.values())]

    def constant(self, suba, subb):
        #values = DataArray([[1,2],[3,4],[5,6]], self.dim_dict)
        return self.constant.values.loc[suba, subb].values
    constant.values = DataArray([[1,2],[3,4],[5,6]], dim_dict)

    def flow(self, suba, subb):
        return self.constant(suba, subb) * self.stock(suba, subb)

    def initial_time(self):
        return 0

------
a = xMinModel()

%%timeit
a.reset_state()
odeint(a.d_dt, a.get_state(), range(10))
10 loops, best of 3: 35.2 ms per loop

Option 3: Unnamed arrays, matrix arithmetic

class fMinModel(object):
    ##########  boilerplate stuff from the existing pysd #########
    def __init__(self):
        self._stocknames = [name[:-5] for name in dir(self) if name[-5:] == '_init']
        self._stocknames.sort() #inplace
        self._dfuncs = [getattr(self, 'd%s_dt'%name) for name in self._stocknames]
        self.state = dict(zip(self._stocknames, [None]*len(self._stocknames)))
        self.reset_state()
        self.functions = functions.Functions(self)

    def reset_state(self):
        """Sets the model state to the state described in the model file. """
        self.t = self.initial_time() #set the initial time
        retry_flag = False
        for key in self.state.keys():
            try:
                self.state[key] = eval('self.'+key+'_init()') #set the initial state
            except TypeError:
                retry_flag = True
        if retry_flag:
            self.reset_state() #potential for infinite loop!

    ########### Stuff we have to modify to make subscripts work #########
    def d_dt(self, state_vector, t):
        """The primary purpose of this function is to interact with the integrator.
        It takes a state vector, sets the state of the system based on that vector,
        and returns a derivative of the state vector
        """        
        self.set_state(state_vector)
        self.t = t

        derivative_vector = []
        for func in self._dfuncs:
            res = func()
            if isinstance(res, np.ndarray):
                res = res.flatten()
            derivative_vector += list(res)

        return derivative_vector

    def set_state(self, state_vector):
        i = 0
        for key in self._stocknames:

            if isinstance(self.state[key], np.ndarray):
                size = self.state[key].size
                elements = state_vector[i:i+size]
                shape = self.state[key].shape
                self.state[key] = np.array(elements).reshape(shape)
                i += size
            else:
                self.state[key] = state_vector[i]
                i += 1


    def get_state(self):
        #if we keep this, we should make it fully a list comprehension
        state_vector = []
        for item in [self.state[key] for key in self._stocknames]:
            if isinstance(item, np.ndarray):
                state_vector += list(item.flatten())
            else:
                state_vector += list(item)
        return state_vector


    ######### model specific components (that go in the model file)

    def stock(self):
        return self.state['stock']

    def stock_1d_sum(self):
        return self.stock().sum(axis=1)

    def stock_1d_max(self):
        return self.stock().max(axis=0)

    def stock_init(self):
        return np.array([[1,1],[1,1],[1,1]])

    def dstock_dt(self):
        return self.flow()

    def constant(self):
        return np.array([[1,2],[3,4],[5,6]])

    def flow(self):
        return self.constant() * self.stock()

    def initial_time(self):
        return 0

----
a = fMinModel()

%%timeit
a.reset_state()
odeint(a.d_dt, a.get_state(), range(10))
100 loops, best of 3: 8.51 ms per loop

Option 4: Xray and matrix multiplication

from xray import DataArray

class xMinModel(object):
    ##########  boilerplate stuff from the existing pysd #########
    def __init__(self):
        self._stocknames = [name[:-5] for name in dir(self) if name[-5:] == '_init']
        self._stocknames.sort() #inplace
        self._dfuncs = [getattr(self, 'd%s_dt'%name) for name in self._stocknames]
        self.state = dict(zip(self._stocknames, [None]*len(self._stocknames)))
        self.reset_state()
        self.functions = functions.Functions(self)

    def reset_state(self):
        """Sets the model state to the state described in the model file. """
        self.t = self.initial_time() #set the initial time
        retry_flag = False
        for key in self.state.keys():
            try:
                self.state[key] = eval('self.'+key+'_init()') #set the initial state
            except TypeError:
                retry_flag = True
        if retry_flag:
            self.reset_state() #potential for infinite loop!

    ########### Stuff we have to modify to make subscripts work #########
    def d_dt(self, state_vector, t):
        """The primary purpose of this function is to interact with the integrator.
        It takes a state vector, sets the state of the system based on that vector,
        and returns a derivative of the state vector
        """        
        self.set_state(state_vector)
        self.t = t

        derivative_vector = []
        for func in self._dfuncs:
            res = func()
            if isinstance(res, DataArray):
                res = res.values.flatten()
            derivative_vector += list(res)

        return derivative_vector

    def set_state(self, state_vector):
        i = 0
        for key in self._stocknames:
            if isinstance(self.state[key], DataArray):
                shape = self.state[key].shape
                size = self.state[key].size
                self.state[key].loc[:,:].values = np.array(state_vector[i:i+size]).reshape(shape)
                i += size
            else:
                self.state[key] = state_vector[i]
                i += 1

    def get_state(self):
        #if we keep this, we should make it fully a list comprehension
        state_vector = []
        for item in [self.state[key] for key in self._stocknames]:
            if isinstance(item, DataArray):
                state_vector += list(item.values.flatten())
            else:
                state_vector += list(item)
        return state_vector


    ######### model specific components (that go in the model file)
    dim_dict = {'suba': ['subb1', 'subb2'],
                'subb': ['suba1', 'suba2', 'suba3']}

    def stock(self):
        return self.state['stock']

    def stock_1d_sum(self):
        return self.stock().sum(dim='suba')

    def stock_1d_max(self):
        return self.stock().max(dim='subb')

    def stock_init(self):
        return DataArray([[1,1],[1,1],[1,1]], self.dim_dict)

    def dstock_dt(self):
        return self.flow()

    def constant(self, *args):
        return DataArray([[1,2],[3,4],[5,6]], self.dim_dict)

    def flow(self):
        return self.constant() * self.stock()

    def initial_time(self):
        return 0


a = xMinModel()
----
%%timeit
a.reset_state()
odeint(a.d_dt, a.get_state(), range(10))
10 loops, best of 3: 21.2 ms per loop

@JamesPHoughton
Copy link
Collaborator Author

It turns out that the flattening and unflattening take a good chunk of time, as does setting up the
integrator. If we run our own Euler integrator, we can avoid a lot of the overhead:

Option 5: matrix math, Euler integration on the raw state dictionary.

class eMinModel(object):
    def __init__(self):
        self._stocknames = [name[:-5] for name in dir(self) if name[-5:] == '_init']
        self._stocknames.sort()
        self._dfuncs = {name: getattr(self, 'd%s_dt' % name) for name in self._stocknames}
        self.state = dict(zip(self._stocknames, [None]*len(self._stocknames)))
        self.reset_state()
        self.functions = functions.Functions(self)
​
    def reset_state(self):
        self.t = self.initial_time() #set the initial time
        retry_flag = False
        for key in self.state.keys():
            try:
                self.state[key] = eval('self.'+key+'_init()') #set the initial state
            except TypeError:
                retry_flag = True
        if retry_flag:
            self.reset_state() #potential for infinite loop!

    def step(self, dt):
        newstate = {}
        for key in self._stocknames:
            newstate[key] = self._dfuncs[key]() * dt + self.state[key]
        self.state = newstate
        self.t = self.t+dt       

    def integrate(self, timesteps):
        outputs = range(len(timesteps))
        for i, t2 in enumerate(timesteps):
            outputs[i] = self.step(t2-self.t)
        return outputs

    ######### model specific components (that go in the model file)

    def stock(self):
        return self.state['stock']

    def stock_init(self):
        return np.array([[1,1],[1,1],[1,1]])

    def dstock_dt(self):
        return self.flow()

    def constant(self):
        return np.array([[1,2],[3,4],[5,6]])

    def constant2(self):
        return 6

    def flow(self):
        return self.constant() * self.stock()

    def initial_time(self):
        return 0
In [39]:

a = eMinModel()
In [40]:

%%timeit
a.reset_state()
a.integrate(np.arange(0,10,.1))
1000 loops, best of 3: 704 µs per loop

For such a simple model, this turns out to be significantly faster, contrary to my expectation.

@JamesPHoughton JamesPHoughton added this to the 1.0 milestone Feb 3, 2016
@JamesPHoughton
Copy link
Collaborator Author

Basic subscript definition and evaluation is functional as of 08468b7, with tests of basic functionality passing:

image

I'm going to go ahead and close this issue, as it has mostly been a discussion about how subscripting should be implemented, and I think that is by and large worked out at this point.

There are two major pieces of functionality still missing, and I've created special issues for them so we can decide how to handle them individually:

It would be great to get some help on these if you guys have ideas.

@JamesPHoughton
Copy link
Collaborator Author

JamesPHoughton commented Jun 28, 2016

And actually, extra special thanks @pbreach and @BaharEs for help on this one, it was a really helpful discussion for me. =)

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

No branches or pull requests

3 participants