Regional Product Portfolio
system dynamics, matrix, arrays, multidimensional, portfolio, revenue, dot product, bptk, bptk-py, python, business simulation
A Regional Product Portfolio
Three products, two regions, and a different story in every cell. The stripe sells steadily in the north and hardly at all in the south. The gizmo is expensive, small in volume, and growing fast. Prices differ by region, and so do the margins.
Six lines of business, then - and everything you want to know spans them. What is total revenue? Which line is the strongest, and by how much? What share does each line carry, and how is that share shifting? Which lines are above average?
A matrix is the natural shape for this: one dimension for regions, one for products. Where the aging chain uses a vector to say “the same structure at every seniority level”, a matrix says “the same structure in every region-and-product combination” - and the aggregations then collapse it back to the numbers a portfolio review actually asks for.
Setting Up the Matrix
setup_named_matrix takes a dictionary of dictionaries: the outer keys are the rows, the inner keys the columns. Every element in this model has the same shape, so the equations can treat the whole portfolio as one object.
| Element | Kind | Meaning |
|---|---|---|
units[region][product] |
stock | units sold per month |
growth[region][product] |
flow | how that volume changes |
price, unit_cost |
constants | the average price and cost of one unit |
revenue, margin, share |
converters | per line of business |
total_revenue, best_line, … |
converters | the portfolio as a whole |
Equations That Hold for Every Cell
Element-wise arithmetic over a matrix pairs cell with cell: north-stripe with north-stripe, south-gizmo with south-gizmo. Volume times price is revenue, price minus cost times volume is margin, and each is written once for all six lines.
The volume itself compounds: each cell grows at its own rate, so growth reads the stock it feeds. That is the one arrayed feedback loop in this model, and it is what makes the mix shift over time.
Collapsing the Matrix to the Numbers You Report
The aggregations take the whole matrix and return one number. All nine work on a matrix exactly as they do on a vector, over every cell:
arr_sum, arr_mean |
the total and the average across all six cells |
arr_max, arr_min |
the strongest and the weakest line |
arr_median |
the middle line - with six cells, the average of the two middle ones |
arr_stddev |
how unevenly the revenue is spread |
arr_rank(2) |
the second strongest line |
arr_prod |
the product of every cell - see the compounding example further down |
arr_size |
the length of the first dimension, so 2 here, not 6 |
That last one is a trap worth knowing: arr_size counts rows, not cells. If you want the number of lines of business, multiply the two dimensions yourself.
An Array Meeting Its Own Aggregation
A share is a cell divided by the total, which means an arrayed element divided by a scalar - and the scalar happens to be an aggregation of that same array. That is allowed, and it reads exactly as you would write it on paper:
Where arr_prod Earns Its Keep
A product of six revenues means nothing. A product of growth factors means quite a lot: it is compounding. Four quarterly factors multiplied together give the factor for the year, and that is a vector aggregation with a genuine use.
Two good quarters, a weak third and a strong fourth: 1.02 x 1.05 x 0.99 x 1.08 gives 1.1451, so 14.5 % of growth over the year. Note that this is not the average quarter compounded - averaging the four factors and raising the result to the fourth power gives a different, wrong answer. arr_prod is the operator that gets it right.
Handing the Model to BPTK
One thing to watch, and it applies to every model rather than to arrays: a scenario manager takes the model’s equations when you register it. Elements added after register_model are not part of any scenario, and plotting one reports that it does not exist. So the model is registered here, once every element above is defined.
Month 0 is where the simulation starts, and it is the leftmost point of the plot below. Every figure is revenue per month - the units a line has on hand times its price in that region:
| Revenue per month | stripe | mohawk | gizmo |
|---|---|---|---|
| north | 58,800 | 63,200 | 37,350 |
| south | 18,000 | 68,400 | 131,400 |
The aggregations turn those six cells into the four series the plot draws: total_revenue adds them up to 377,150, best_line picks the southern gizmo at 131,400, median_line sits at 61,000, and weakest_line is the southern stripe at 18,000. A factor of seven between the best and the worst cell, which is what the standard deviation of 35,138 is saying; the second strongest line is the northern mohawk at 68,400. Total margin is 164,050 a month.
From month 1 on every cell grows at its own rate, so those four lines drift apart - and watching them drift is the point of the plot.
The Mix Shifts
Because every cell grows at its own rate, the portfolio in month 24 is not the portfolio you started with. The gizmo compounds at 8 % a month in the north and 5 % in the south; the stripe crawls along at 1 % and 3 %.
| Revenue per month | stripe | mohawk | gizmo |
|---|---|---|---|
| north | 74,660 | 101,653 | 236,843 |
| south | 36,590 | 97,778 | 423,778 |
Revenue per month has gone from 377,150 to 971,303 and margin per month from 164,050 to 443,543 - both about two and a half times. But the shape has changed more than the size: the two gizmo lines were 45 % of revenue in month 0 and are 68 % by month 24. The northern gizmo alone goes from a tenth of the portfolio to a quarter of it.
The plot below is where that shift becomes visible, and it deliberately does not draw the numbers in the table. It draws share - each cell divided by the total - so a line stays flat while it merely keeps up with the portfolio and only moves when its weight changes.
Watch above_average for a subtlety worth understanding. In month 0, three of the six lines are above the mean; in month 12 only one is - not because the others shrank, but because the southern gizmo has grown enough to drag the mean up past them. By month 24 there are two again, the northern gizmo having caught up with the mean it was below. A threshold that is itself an aggregation moves with the data.
Note the naming: a cell of a matrix is addressed with nested brackets - share[north][gizmo]. That is the name in a plot, in a scenario constant and in the simulation results, and it is the name the Rust engine sees as well.
The Dot Product
Everything so far has been element-wise: cells paired with matching cells. The dot product is the operator that does something else - it sums across a dimension, and that is what turns a matrix of volumes and a list of prices into revenue per region.
Written out, revenue in the north is units[north][stripe] * price[stripe] + units[north][mohawk] * price[mohawk] + .... That is exactly a matrix times a vector: a 2x3 matrix of volumes and a vector of three prices give a vector of two revenues.
One restriction to know: dot works on unnamed arrays only. A named matrix raises rather than guessing how to line up the labels, so the model below uses positional indices - rows 0 and 1 for the regions, columns 0, 1 and 2 for the products - and a comment to say which is which. That is a real limitation and worth stating plainly; for element-wise work and the aggregations, named arrays are the better choice.
With one price list applied to both regions, the north brings in 159,350 a month and the south 244,050, for a group total of 403,400. The northern figure is the same 159,350 the named model produced, because the northern prices are the list prices there; the southern figure is higher than the named model’s 217,800, and that difference is the model speaking rather than a mistake. A matrix times a vector of prices says “one price list for every region”. If prices really differ by region, the element-wise units * price above is the honest formulation.
Matrix Times Matrix
Give the second operand a second dimension and the same operator answers a bigger question. Put the price and the unit cost of each product side by side - a 3x2 matrix, products by measure - and multiply:
volume (2x3) . rates (3x2) gives a 2x2 matrix: for each region, its revenue and its cost. The margin per region is then one subtraction away.
| revenue | cost | margin | |
|---|---|---|---|
| north | 159,350 | 90,400 | 68,950 |
| south | 244,050 | 126,200 | 117,850 |
One operator, and the whole regional profit and loss falls out. This is the case where the dot product pays for the positional indices: written element-wise, the same result needs six products, two sums per region and a subtraction.
Experiment With the Portfolio
Two levers, each addressing a single cell of a matrix by its nested name. The price of the northern stripe is a pricing decision; the growth rate of the southern gizmo is the bet on the fastest-moving line.
Worth trying: drop the northern stripe price and watch how little the total moves - it is a large volume at a small price, and the portfolio no longer lives there. Then take the southern gizmo growth to 8 % and watch the mix run away with itself.
Two Limits This Model Ran Into
Both are worth naming because the model had to work around them, and both are in the reference page’s list along with the rest.
dotneeds unnamed arrays, which is why the section above switches from region and product names to positional indices.- An aggregation always takes the whole array. There is no “sum along the rows”, so the per-region totals came from a dot product rather than from
arr_sum. Where a row total is all you need, address the row itself:revenue["north"].arr_sum()works, because a row of a named matrix is a named vector in its own right.