Skip to content

Commit 647bd3b

Browse files
authored
Merge pull request #28 from lantunes/memo_recursive
Recursive Memoization
2 parents 15cb9f9 + 44e4eb6 commit 647bd3b

19 files changed

+976
-49
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Added support for `memoize='recursive'` option of `evolve` and `evolve2d` functions
13+
- Added `NKSRule`, `BinaryRule` and `TotalisticRule` classes
14+
1015
## [2.2.0] - 2021-11-30
1116

1217
### Added

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,7 @@ cellular_automaton[:, [18,18,19,20,21,21,21,21,20], [45,48,44,44,44,45,46,47,48]
251251

252252
# evolve the cellular automaton for 60 time steps
253253
cellular_automaton = cpl.evolve2d(cellular_automaton, timesteps=60, neighbourhood='Moore',
254-
apply_rule=cpl.game_of_life_rule)
254+
apply_rule=cpl.game_of_life_rule, memoize='recursive')
255255

256256
cpl.plot2d_animate(cellular_automaton)
257257
```

cellpylib/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@
77
For complete documentation, see: https://cellpylib.org
88
"""
99

10-
__version__ = "2.2.0"
10+
__version__ = "2.3.0"
1111

1212
from .ca_functions import BaseRule, AsynchronousRule, ReversibleRule, binary_rule, init_simple, nks_rule, \
13-
totalistic_rule, plot_multiple, bits_to_int, int_to_bits, init_random, plot, evolve, until_fixed_point
13+
totalistic_rule, plot_multiple, bits_to_int, int_to_bits, init_random, plot, evolve, until_fixed_point, NKSRule, \
14+
BinaryRule, TotalisticRule
1415

1516
from .rule_tables import random_rule_table, table_walk_through, table_rule
1617

cellpylib/ca_functions.py

Lines changed: 210 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -84,11 +84,16 @@ def evolve(cellular_automaton, timesteps, apply_rule, r=1, memoize=False):
8484
8585
:param r: the neighbourhood radius; the neighbourhood size will be 2r + 1 (default is 1)
8686
87-
:param memoize: if True, then the result of applying the rule on a given neighbourhood will be cached, and used on
88-
subsequent invocations of the rule; this can result in a significant improvement to execution speed
89-
if the rule is expensive to invoke; NOTE: this should only be set to True for rules which do not
90-
store any state upon invocation, and for rules which do not depend in the cell index or timestep
91-
number (default is False)
87+
:param memoize: allowed values are True, False, and "recursive"; if True, then the result of applying the rule on a
88+
given neighbourhood will be cached, and used on subsequent invocations of the rule; if "recursive",
89+
then a recursive memoized algorithm will be used, in which recursively wider neighbourhoods are
90+
cached, along with the result of applying the rule on the cells in the widened neighbourhood; the
91+
True and "recursive" options can result in a significant improvement to execution speed if the rule
92+
is expensive to invoke; the "recursive" option works best when there are strongly repetitive
93+
patterns in the CA, and when the state consists of 2^k cells; if False, then no caching will be
94+
used; NOTE: this should only be set to True or "recursive" for rules which do not store any state
95+
upon invocation, and for rules which do not depend in the cell index or timestep number (default is
96+
False)
9297
9398
:return: a matrix, containing the results of the evolution, where the number of rows equal the number of time steps
9499
specified
@@ -119,7 +124,7 @@ def _evolve_fixed(cellular_automaton, timesteps, apply_rule, r, memoize):
119124
120125
:param r: the neighbourhood radius; the neighbourhood size will be 2r + 1
121126
122-
:param memoize: whether to use memoization
127+
:param memoize: the memoization flag; one of True, False, or "recursive"
123128
124129
:return: a matrix, containing the results of the evolution, where the number of rows equal the number of time steps
125130
specified
@@ -128,17 +133,24 @@ def _evolve_fixed(cellular_automaton, timesteps, apply_rule, r, memoize):
128133
_, cols = cellular_automaton.shape
129134
array = np.zeros((timesteps, cols), dtype=cellular_automaton.dtype)
130135
array[0] = initial_conditions
136+
cell_indices = list(range(len(initial_conditions)))
131137

132138
memo_table = {}
133139

134140
for t in range(1, timesteps):
135141
cells = array[t - 1]
136142
strides = _index_strides(np.arange(len(cells)), 2 * r + 1)
137143
neighbourhoods = cells[strides]
138-
if memoize:
144+
if memoize is "recursive":
145+
next_state = np.zeros(len(cells), dtype=cellular_automaton.dtype)
146+
_step(cell_indices, cells, next_state, memo_table, apply_rule, r, t)
147+
array[t] = next_state
148+
elif memoize is True:
139149
array[t] = np.array([_get_memoized(n, c, t, apply_rule, memo_table) for c, n in enumerate(neighbourhoods)])
140-
else:
150+
elif memoize is False:
141151
array[t] = np.array([apply_rule(n, c, t) for c, n in enumerate(neighbourhoods)])
152+
else:
153+
raise Exception("unsupported memoization option: %s" % memoize)
142154

143155
return np.concatenate((cellular_automaton, array[1:]), axis=0)
144156

@@ -164,14 +176,15 @@ def _evolve_dynamic(cellular_automaton, timesteps, apply_rule, r, memoize):
164176
165177
:param r: the neighbourhood radius; the neighbourhood size will be 2r + 1
166178
167-
:param memoize: whether to use memoization
179+
:param memoize: the memoization flag; one of True, False, or "recursive"
168180
169181
:return: a matrix, containing the results of the evolution, where the number of rows equal the number of time steps
170182
specified
171183
"""
172184
initial_conditions = cellular_automaton[-1]
173185
_, cols = cellular_automaton.shape
174186
array = [initial_conditions]
187+
cell_indices = list(range(len(initial_conditions)))
175188

176189
memo_table = {}
177190

@@ -180,16 +193,89 @@ def _evolve_dynamic(cellular_automaton, timesteps, apply_rule, r, memoize):
180193
cells = array[-1]
181194
strides = _index_strides(np.arange(len(cells)), 2 * r + 1)
182195
neighbourhoods = cells[strides]
183-
if memoize:
196+
if memoize is "recursive":
197+
result = np.zeros(len(cells), dtype=cellular_automaton.dtype)
198+
_step(cell_indices, cells, result, memo_table, apply_rule, r, t)
199+
elif memoize is True:
184200
result = [_get_memoized(n, c, t, apply_rule, memo_table) for c, n in enumerate(neighbourhoods)]
185-
else:
201+
elif memoize is False:
186202
result = [apply_rule(n, c, t) for c, n in enumerate(neighbourhoods)]
203+
else:
204+
raise Exception("unsupported memoization option: %s" % memoize)
187205
array.append(np.array(result, dtype=cellular_automaton.dtype))
188206
t += 1
189207

190208
return np.concatenate((cellular_automaton, array[1:]), axis=0)
191209

192210

211+
def _step(indices, curr_state, next_state, cache, apply_rule, r, t):
212+
"""
213+
Perform an update on the given next state using the current state and memoization cache, based on
214+
an even split of the cell indices.
215+
216+
:param indices: a list of the cell indices of the cells to update
217+
218+
:param curr_state: the current state (i.e. the state after the previous timestep)
219+
220+
:param next_state: the next state (i.e. the result after the current timestep)
221+
222+
:param cache: a dictionary that maps state neighbourhoods to their activities
223+
224+
:param apply_rule: the rule to apply during each cell update
225+
226+
:param r: the neighbourhood radius
227+
228+
:param t: the current timestep
229+
"""
230+
mid = len(indices) // 2
231+
left_indices = indices[:mid]
232+
right_indices = indices[mid:]
233+
if len(left_indices) > 0:
234+
_update_state(left_indices, curr_state, next_state, cache, apply_rule, r, t)
235+
if len(right_indices) > 0:
236+
_update_state(right_indices, curr_state, next_state, cache, apply_rule, r, t)
237+
238+
239+
def _update_state(indices, curr_state, next_state, cache, apply_rule, r, t):
240+
"""
241+
Perform an update on the given next state using the current state and memoization cache.
242+
243+
:param indices: a list of the cell indices of the cells to update
244+
245+
:param curr_state: the current state (i.e. the state after the previous timestep)
246+
247+
:param next_state: the next state (i.e. the result after the current timestep)
248+
249+
:param cache: a dictionary that maps state neighbourhoods to their activities
250+
251+
:param apply_rule: the rule to apply during each cell update
252+
253+
:param r: the neighbourhood radius
254+
255+
:param t: the current timestep
256+
"""
257+
# get the state string for the state given by the indices
258+
start = indices[0]
259+
end = indices[-1]
260+
neighbourhood_indices = range(start - r, end + 1 + r)
261+
neighbourhood = curr_state.take(neighbourhood_indices, mode='wrap')
262+
state_string = neighbourhood.tobytes()
263+
264+
if state_string in cache:
265+
# update next_state with next vals from cache
266+
next_state[indices] = cache[state_string]
267+
else:
268+
if len(indices) > 1:
269+
_step(indices, curr_state, next_state, cache, apply_rule, r, t)
270+
else:
271+
# invoke rule and update next_state for cell
272+
val = apply_rule(neighbourhood, start, t)
273+
next_state[start] = val
274+
# get the result from the next_state for the left_indices and place in cache
275+
vals_to_cache = next_state[indices]
276+
cache[state_string] = vals_to_cache
277+
278+
193279
def _get_memoized(n, c, t, apply_rule, memoization_table):
194280
"""
195281
Checks if the result of `apply_rule` is in the memoization table according to the neighbourhood, `n`,
@@ -446,6 +532,119 @@ def __call__(self, n, c, t):
446532
raise NotImplementedError
447533

448534

535+
class NKSRule(BaseRule):
536+
"""
537+
An Elementary Cellular Automaton rule, indexed according the scheme in NKS.
538+
"""
539+
def __init__(self, nks_rule_number):
540+
"""
541+
Creates an instance of an NKS rule.
542+
543+
:param nks_rule_number: an int indicating the cellular automaton rule number
544+
"""
545+
self._nks_rule_number = nks_rule_number
546+
547+
def __call__(self, n, c, t):
548+
"""
549+
The NKS rule to apply.
550+
551+
:param n: a binary array of length 2r + 1
552+
553+
:param c: the index of the current cell
554+
555+
:param t: the current timestep
556+
557+
:return: the result, 0 or 1, of applying the given rule on the given state
558+
"""
559+
return nks_rule(n, self._nks_rule_number)
560+
561+
562+
class BinaryRule(BaseRule):
563+
"""
564+
A binary representation of the given rule number, which is used to determine the value to return.
565+
The process is approximately described as:
566+
567+
.. code-block:: text
568+
569+
1. convert state to int, so [1,0,1] -> 5, call this state_int
570+
571+
2. convert rule to binary, so 254 -> [1,1,1,1,1,1,1,0], call this rule_bin_array
572+
573+
3. new value is rule_bin_array[7 - state_int]
574+
we subtract 7 from state_int to be consistent with the numbering scheme used in NKS
575+
in NKS, rule 254 for a 1D binary cellular automaton is described as:
576+
577+
[1,1,1] [1,1,0] [1,0,1] [1,0,0] [0,1,1] [0,1,0] [0,0,1] [0,0,0]
578+
1 1 1 1 1 1 1 0
579+
580+
If None is provided for the scheme parameter, the neighbourhoods are listed in lexicographic order (the reverse of
581+
the NKS convention). If 'nks' is provided for the scheme parameter, the NKS convention is used for listing the
582+
neighbourhoods.
583+
"""
584+
def __init__(self, rule, scheme=None, powers_of_two=None):
585+
"""
586+
Creates an instance of a binary rule.
587+
588+
:param rule: an int or a binary array indicating the cellular automaton rule number
589+
590+
:param scheme: can be None (default) or 'nks'; if 'nks' is given, the rule numbering scheme used in NKS is used
591+
592+
:param powers_of_two: a pre-computed array containing the powers of two, e.g. [4,2,1]; can be None (default) or
593+
an array of length len(neighbourhood); if an array is given, it will used to speed up the
594+
calculation of state_int
595+
"""
596+
self._rule = rule
597+
self._scheme = scheme
598+
self._powers_of_two = powers_of_two
599+
600+
def __call__(self, n, c, t):
601+
"""
602+
The binary rule to apply.
603+
604+
:param n: a binary array of length 2r + 1
605+
606+
:param c: the index of the current cell
607+
608+
:param t: the current timestep
609+
610+
:return: the result, 0 or 1, of applying the given rule on the given state
611+
"""
612+
return binary_rule(n, self._rule, self._scheme, self._powers_of_two)
613+
614+
615+
class TotalisticRule(BaseRule):
616+
"""
617+
The totalistic rule as described in NKS. The average color is mapped to a whole number in [0, k - 1].
618+
The rule number is in base 10, but interpreted in base k. For a 1-dimensional cellular automaton, there are
619+
3k - 2 possible average colors in the 3-cell neighbourhood. There are n(k - 1) + 1 possible average colors for a
620+
k-color cellular automaton with an n-cell neighbourhood.
621+
"""
622+
def __init__(self, k, rule):
623+
"""
624+
Creates an instance of a totalistic rule.
625+
626+
:param k: the number of colors in this cellular automaton, where only 2 <= k <= 36 is supported
627+
628+
:param rule: the k-color cellular automaton rule number in base 10, interpreted in base k
629+
"""
630+
self._k = k
631+
self._rule = rule
632+
633+
def __call__(self, n, c, t):
634+
"""
635+
The totalistic rule to apply.
636+
637+
:param n: a k-color array of any size
638+
639+
:param c: the index of the current cell
640+
641+
:param t: the current timestep
642+
643+
:return: the result, a number from 0 to k - 1, of applying the given rule on the given state
644+
"""
645+
return totalistic_rule(n, self._k, self._rule)
646+
647+
449648
class ReversibleRule(BaseRule):
450649
"""
451650
An elementary cellular automaton rule explicitly set up to be reversible.

0 commit comments

Comments
 (0)