Modeling Toolkit

Everything you need to build models, grouped by purpose. Each section has a table of the functions and a small example you can run. For a guided first model, start with the Quick start; for complete models, see the Templates.

The one idea to keep in mind: variables are only what Seeker searches over; everything else is derived. Choose few variables of the right kind, compute the rest, and use constraints only for true limits.

Environment and solving

An environment holds one model. You create it, build the model, solve it once, read the results and end it. For several scenarios, create one environment per scenario.

Function What it does
skr.Env(license='', stochastic=False) Create an environment. '' uses the free demo; pass True as second argument for models with uncertainty.
env.minimize(term, seconds, bound) Search for the smallest value of term. bound is optional: a value that cannot be beaten, so Seeker can stop early. Returns the best value.
env.maximize(term, seconds, bound) The same, for the largest value.
env.multi_objective(terms, fair, excellent, maximize, seconds) Several goals at once. For each goal give a fair (acceptable) and an excellent value; Seeker looks for the best compromise. See the assignment template.
env.evaluate() Compute all terms for the current values without searching. Useful to evaluate a given plan, for example today's plan as a baseline.
env.set_report(seconds, terms, names) Print progress every few seconds while solving, including the values of the terms you list.
env.get_status() After solving: optimal, bounded (the bound was reached), timeout, or an infeasibility status.
env.end() Release the environment. Always call it at the end.

Example

%pip install -q seekerdemo "setuptools<81"
import seekerdemo as skr

env = skr.Env('')                       # create the environment
x = env.continuous(-5, 5)               # one decision variable
f = env.sqr(x - 1.5) + 2                # a derived term
best = env.minimize(f, 1, 2)            # term, seconds, known lower bound (optional)
print(f"best value {best:.4f} at x = {x.get_value():.4f}, status: {env.get_status()}")
env.end()                               # always end the environment
best value 2.0000 at x = 1.5001, status: StatusType.bounded

Decision variables

Variables are what Seeker searches over. Everything else is computed from them. The type tells Seeker which values are neighbors, so it matters for how well the search works.

Function What it does
env.continuous(low, high, init) A real number in [low, high]. init (optional) is the starting value.
env.ordinal(low, high, init) A whole number in [low, high] where nearby values are similar: quantities, hours, levels.
env.categorical(low, high, allowed, init) A label in [low, high] where values are not ordered: which machine, which supplier, which color. allowed (optional) restricts the labels, for example to the suppliers that can make a part.

Example

%pip install -q seekerdemo "setuptools<81"
import seekerdemo as skr

env = skr.Env('')
temp  = env.continuous(150, 250)           # any real number in the range
batch = env.ordinal(1, 12)                 # integers; 6 is "close" to 7
color = env.categorical(0, 3)              # labels 0..3; 1 is not "close" to 2
oven  = env.categorical(0, 5, [0, 2, 5])   # only these labels are allowed
fill  = env.ordinal(0, 100, 40)            # integer, search starts at 40

score = (env.sqr(temp - 182.5) + env.sqr(batch - 7) + env.sqr(fill - 55)
         + env.if_(color == 2, 0, 10) + env.if_(oven == 5, 0, 10))
env.minimize(score, 1)
print(f"temp {temp.get_value():.1f}, batch {batch.get_value():.0f}, "
      f"color {color.get_value():.0f}, oven {oven.get_value():.0f}, fill {fill.get_value():.0f}")
env.end()
temp 182.5, batch 7, color 2, oven 5, fill 55

Arithmetic and math functions

Python operators work directly on terms and mix freely with numbers. env.convert(x) turns a number or a list of numbers into terms when a function expects terms.

Function What it does
+ - * / % -x Arithmetic. % is the remainder.
x ^ k, env.power(x, k) Power. Python's ** is not supported; use ^ or power.
env.sqr(x), env.sqrt(x) Square and square root.
env.exp(x), env.log(x) Exponential and natural logarithm.
env.sin(x), env.cos(x) Trigonometry.
env.abs(x), env.abs(x, y) Absolute value; with two arguments the distance |x − y|.
env.floor, ceil, round, trunc Rounding.
env.round_div(x, k), env.round_mod(x, k) Integer division and remainder of the rounded value.
env.max_0(x), env.min_0(x) max(x, 0) and min(x, 0): the positive or negative part, for example overtime or shortfall.
env.convert(x) A constant term, or a list of constant terms.

Example

%pip install -q seekerdemo "setuptools<81"
import seekerdemo as skr

env = skr.Env('')
a, b = env.convert(7.5), env.convert(2.0)   # constants as terms
show = {
    "a + b, a - b, a * b, a / b": [a + b, a - b, a * b, a / b],
    "a % b, -a, a ^ 2":           [a % b, -a, a ^ 2],
    "sqr, sqrt, power(a, 1.5)":   [env.sqr(a), env.sqrt(a), env.power(a, 1.5)],
    "exp(b), log(a)":             [env.exp(b), env.log(a)],
    "abs(-a), abs(a, b) = |a-b|": [env.abs(-a), env.abs(a, b)],
    "floor, ceil, round, trunc":  [env.floor(a), env.ceil(a), env.round(a), env.trunc(-a)],
    "round_div, round_mod":       [env.round_div(a, 2), env.round_mod(a, 2)],
    "max_0(a-9), min_0(a-9)":     [env.max_0(a - 9), env.min_0(a - 9)],
}
env.evaluate()                              # compute all terms, no search
for label, terms in show.items():
    print(f"{label:28}", [round(t.get_value(), 3) for t in terms])
env.end()
a + b, a - b, a * b, a / b   [9.5, 5.5, 15.0, 3.75]
a % b, -a, a ^ 2             [1.5, -7.5, 56.25]
sqr, sqrt, power(a, 1.5)     [56.25, 2.739, 20.54]
exp(b), log(a)               [7.389, 2.015]
abs(-a), abs(a, b) = |a-b|   [7.5, 5.5]
floor, ceil, round, trunc    [7.0, 8.0, 8.0, -7.0]
round_div, round_mod         [4.0, 0.0]
max_0(a-9), min_0(a-9)       [0.0, -1.5]

Comparisons, logic and if_

Comparisons between terms give terms that are 1 (true) or 0 (false). They can be added up, multiplied, and used as conditions.

Function What it does
x < y, x <= y, x == y, x != y, x >= y, x > y 0/1 terms. Sum them to count, multiply by a cost to charge for a condition.
env.if_(cond, then, otherwise) then if cond is non-zero, else otherwise. Nest them for tiers and piecewise functions.
env.and_([terms]), env.or_([terms]) Logical and/or of 0/1 terms.
term.not_() Logical not.

Example

%pip install -q seekerdemo "setuptools<81"
import seekerdemo as skr

env = skr.Env('')
weights = [3.0, 12.0, 30.0]                   # parcel weights in kg
for kg in weights:
    w = env.convert(kg)
    # tiered shipping fee: flat up to 5 kg, then per kg, cheaper above 20 kg
    fee = env.if_(w <= 5, 6.0,
                  env.if_(w <= 20, 6.0 + 0.8 * (w - 5), 18.0 + 0.5 * (w - 20)))
    heavy = w > 10                            # comparisons give 0/1 terms
    bulky = env.and_([heavy, w < 25])         # logical AND of 0/1 terms
    env.evaluate()
    print(f"{kg:4.0f} kg: fee {fee.get_value():5.2f}, heavy {heavy.get_value():.0f}, "
          f"heavy but not over 25 kg {bulky.get_value():.0f}, not heavy {heavy.not_().get_value():.0f}")
env.end()
   3 kg: fee  6.00, heavy 0, heavy but not over 25 kg 0, not heavy 1
  12 kg: fee 11.60, heavy 1, heavy but not over 25 kg 1, not heavy 0
  30 kg: fee 23.00, heavy 1, heavy but not over 25 kg 0, not heavy 0

Aggregations over lists

Aggregations take a list of terms and give one term. Seeker updates them incrementally, so a sum over thousands of terms stays fast.

Function What it does
env.sum, env.prod, env.mean Sum, product, average.
env.min, env.max Smallest and largest value: a makespan, a peak load, a worst case.
env.argmin, env.argmax Position of the smallest or largest value (first one on ties).
env.minimum, env.maximum Both at once: returns [position, value].
env.ith_smallest_value(i, terms), env.ith_largest_value(i, terms) The i-th smallest or largest (i starts at 0). Returns [value, position].
env.scalar_product(a, b) Sum of a[i] * b[i].
env.norm1, env.norm2 Sum of absolute values; Euclidean length.
env.standard_deviation, env.variance Spread of the values: fairness, balance.
env.sum_if, max_if, mean_if, … The same aggregations, restricted to items that meet a condition. See conditional aggregation below.

Example

%pip install -q seekerdemo "setuptools<81"
import seekerdemo as skr

env = skr.Env('')
v = env.convert([5.0, 1.0, 9.0, 3.0])          # a list of terms
w = env.convert([1.0, 2.0, 0.5, 1.0])
show = {
    "sum, prod, mean":            [env.sum(v), env.prod(v), env.mean(v)],
    "min, max":                   [env.min(v), env.max(v)],
    "argmin, argmax (position)":  [env.argmin(v), env.argmax(v)],
    "maximum -> [position, value]":   env.maximum(v),
    "ith_largest_value(1, v) -> [value, position]": env.ith_largest_value(1, v),
    "scalar_product(v, w)":       [env.scalar_product(v, w)],
    "norm1, norm2":               [env.norm1(v), env.norm2(v)],
    "standard_deviation, variance": [env.standard_deviation(v), env.variance(v)],
}
env.evaluate()
for label, terms in show.items():
    print(f"{label:46}", [round(t.get_value(), 3) for t in terms])
env.end()
sum, prod, mean                                [18.0, 135.0, 4.5]
min, max                                       [1.0, 9.0]
argmin, argmax (position)                      [1.0, 2.0]
maximum -> [position, value]                   [2.0, 9.0]
ith_largest_value(1, v) -> [value, position]   [5.0, 0.0]
scalar_product(v, w)                           [14.5]
norm1, norm2                                   [18.0, 10.77]
standard_deviation, variance                   [2.958, 8.75]

Looking up data with a variable (tensors, right-indexing)

When a decision selects a row or column of a table (which site, which product is in position k), put the table into a tensor and index it with variables. This replaces the many yes/no variables and linking constraints other solvers need.

Function What it does
env.tensor(data) Wrap a list, or nested lists up to 6 dimensions, so the model can read from it.
env.index([i, j, …], tensor) The entry at position (i, j, …), where i, j are terms. Index values must stay inside the table: give index variables matching ranges.

Example

%pip install -q seekerdemo "setuptools<81"
import seekerdemo as skr

# Where to open one depot so that the farthest of 3 customers is as close as possible?
km = [[12, 40, 25],          # km[site][customer]
      [30, 18, 22],
      [45, 35, 10],
      [20, 26, 30]]
env = skr.Env('')
KM = env.tensor(km)                      # data the model can look up
site = env.categorical(0, 3)             # the decision: which site
dist = [env.index([site, env.convert(c)], KM) for c in range(3)]   # km[site][c]
worst = env.max(dist)
env.minimize(worst, 0.5)
print(f"open site {site.get_value():.0f}: distances {[d.get_value() for d in dist]}, worst {worst.get_value():.0f} km")
env.end()
open site 1: distances [30.0, 18.0, 22.0], worst 30 km

Writing at a variable position (canvases, left-indexing)

The opposite direction: the decision says where a value goes. A canvas is a table the model can write into at positions chosen by variables, and read back. Typical use: a decoder that updates the state of a resource the search picked (machine free times, vehicle positions, slot contents).

Function What it does
env.canvas(data) A writable table (1 to 6 dimensions) with starting values.
env.assignment(canvas, [[i, …], …], [value, …]) A new canvas with the values written at the given positions. Writes replace, they do not add up. Do not write twice to the same position in one call; positions outside the canvas are ignored.
env.index([i, …], canvas) Read an entry, just like a tensor.
canvas.to_1d(), to_2d(), … All entries as a list of terms, for example to take the max.
canvas.slice(dims, values) Fix some dimensions and get a smaller canvas.
Tip Use a tensor when you only read; it is faster. Use a canvas when the model writes.

Example

%pip install -q seekerdemo "setuptools<81"
import seekerdemo as skr

# Four jobs in a fixed order go to two machines. Each job starts when its
# chosen machine becomes free, and then makes that machine busy until it ends.
dur = [4, 3, 5, 2]
env = skr.Env('')
machine = [env.categorical(0, 1) for _ in dur]          # decision per job
free = env.canvas([0.0, 0.0])                           # when each machine is free
starts = []
for j, d in enumerate(dur):
    start = env.index([machine[j]], free)               # READ at a variable position
    free = env.assignment(free, [[machine[j]]], [start + d])   # WRITE at a variable position
    starts.append(start)
makespan = env.max(free.to_1d())
env.minimize(makespan, 0.5)
for j in range(len(dur)):
    print(f"job {j}: machine {machine[j].get_value():.0f}, start {starts[j].get_value():.0f}")
print("all done at", makespan.get_value())
env.end()
job 0: machine 0, start 0
job 1: machine 0, start 4
job 2: machine 1, start 0
job 3: machine 1, start 5
all done at 7.0

Conditional aggregation

Sum (or average, max, …) only the items that meet a condition, without writing an if_ per item. Three kinds of conditions cover most needs.

Function What it does
env.sum_if(terms, partition, k) Sum of the items that a partition (or packing) puts into group k.
env.switch_condition(x, n) A condition on a term with values 0..n−1. env.sum_if(terms, [conds], k) sums the items whose condition equals k: the load of machine k when each job's machine is a categorical.
env.interval_condition(x) A condition "x lies in [low, high]". env.sum_if(terms, conds, lows, highs) sums the items whose term lies in its interval: the people at work in hour t when tasks have start times.
env.bool(condition, …) The underlying 0/1 term for one item, if you need it directly.
max_if, min_if, mean_if, prod_if, … The same conditions work for every aggregation.

Example

%pip install -q seekerdemo "setuptools<81"
import seekerdemo as skr

# Five maintenance tasks must fit into an 8-hour shift. Each needs a crew for
# several hours. Choose start hours so that the busiest hour needs few people.
dur, crew, H = [3, 2, 4, 1, 2], [2, 1, 2, 3, 1], 8
env = skr.Env('')
start = [env.ordinal(0, H - d) for d in dur]
cond = [env.interval_condition(s) for s in start]       # "is start in [low, high]?"
people = env.convert([float(c) for c in crew])
# a task occupies hour t if it started in [t - dur + 1, t]
load = [env.sum_if(people, cond, [t - d + 1 for d in dur], [t] * len(dur)) for t in range(H)]
peak = env.max(load)
env.minimize(peak, 1)
print("starts:", [int(s.get_value()) for s in start])
print("people per hour:", [int(l.get_value()) for l in load], "peak", peak.get_value())
env.end()
starts: [5, 5, 1, 0, 2]
people per hour: [3, 2, 3, 3, 2, 3, 3, 2] peak 3.0

Structured variables

These variables carry common rules by construction, so the rules never have to be written as constraints and the search never leaves them. They are usually the biggest single improvement to a model.

Function What it does
env.permutation(n).get_permutation() Each of 0..n−1 exactly once, in some order: sequences, rankings, positions. get_permutation_inverse() gives the position of each item. Precedences can be given at creation.
env.convex_combination(n).get_combination() n shares ≥ 0 that sum to 1: blends, portfolios, splits of demand.
env.split_total(n, total).get_combination() n whole numbers ≥ 0 that sum to total: staff, vehicles or budget blocks across areas.
env.partition(k, n) Each of n items in exactly one of k groups. Use with sum_if; get_variables() gives the group of each item.
env.packing(k, n) Like a partition, plus one extra set k for "left out": choose which items to do and where.
env.int_sorting(terms), float_sorting, partial_sorting Sorted view of a list of terms (derived, not a variable): get() gives the sorted values.

Example

%pip install -q seekerdemo "setuptools<81"
import seekerdemo as skr

env = skr.Env('')
order  = env.permutation(4).get_permutation()       # each of 0..3 exactly once
shares = env.convex_combination(3).get_combination() # 3 numbers >= 0 that sum to 1
crews  = env.split_total(3, 10).get_combination()    # 3 integers >= 0 that sum to 10
groups = env.partition(2, 6)                         # each of 6 items in group 0 or 1
packed = env.packing(2, 6)                           # each item in set 0, set 1, or 2 = left out

size = env.convert([4.0, 7.0, 2.0, 5.0, 6.0, 3.0])
target = (env.sum([env.abs(order[k], [2, 0, 3, 1][k]) for k in range(4)])
          + env.sum([env.sqr(shares[i] - [.5, .3, .2][i]) for i in range(3)])
          + env.sum([env.sqr(crews[i] - [5, 3, 2][i]) for i in range(3)])
          + env.abs(env.sum_if(size, groups, 0), env.sum_if(size, groups, 1))   # equal halves
          + env.abs(env.sum_if(size, packed, 0), 9) + env.abs(env.sum_if(size, packed, 1), 8))
env.minimize(target, 1)
show = lambda ts: [round(t.get_value(), 2) for t in ts]
print("order ", show(order));  print("shares", show(shares)); print("crews ", show(crews))
print("groups", show(groups.get_variables())); print("packed", show(packed.get_variables()))
env.end()
order  [2.0, 0.0, 3.0, 1.0]
shares [0.5, 0.3, 0.2]
crews  [5.0, 3.0, 2.0]
groups [0.0, 0.0, 0.0, 1.0, 1.0, 1.0]
packed [2.0, 0.0, 0.0, 1.0, 2.0, 1.0]

Constraints

Constraints must hold in every solution. Prefer building rules into variables; use constraints for real limits such as capacities and budgets. Give them names: if Seeker cannot satisfy them, it reports which ones fail.

Function What it does
env.enforce_leq(a, b, name), enforce_geq, enforce_eq, enforce_neq, enforce_lt, enforce_gt a ≤ b, a ≥ b, a = b, a ≠ b, a < b, a > b. name is optional.
env.leq(a, b), geq, eq, neq, lt, gt The same comparison as a constraint object, not yet enforced.
env.or_([constraints]), env.and_([constraints]) Combine constraint objects, for example "off or at least 4 hours".
env.enforce(constraint, name) Enforce a combined constraint.
env.unenforce(constraint) Remove an enforced constraint object again.
env.fulfills_all_constraints_posted_so_far() A 0/1 term: are all constraints met?

Example

%pip install -q seekerdemo "setuptools<81"
import seekerdemo as skr

# Two machines, hours per day. Each machine is either off or runs at least 4 h.
env = skr.Env('')
h = [env.ordinal(0, 10) for _ in range(2)]
output = 30 * h[0] + 45 * h[1]
cost = 25 * h[0] + 50 * h[1] + env.sqr(h[0] - h[1])
env.enforce_geq(output, 300, "demand")                 # simple constraint, with a name
for x in h:                                            # either/or constraint
    env.enforce(env.or_([env.eq(x, 0), env.geq(x, 4)]))
env.enforce_neq(h[0], h[1])                            # not the same hours
ok = env.fulfills_all_constraints_posted_so_far()      # 1 if all constraints hold
env.minimize(cost, 1)
print(f"hours {[x.get_value() for x in h]}, output {output.get_value():.0f}, "
      f"cost {cost.get_value():.0f}, all constraints met: {ok.get_value():.0f}")
env.end()
hours [5.0, 4.0], output 330, cost 326, all constraints met: 1

Uncertainty

In a stochastic environment, random terms hold many sampled values at once, and everything computed from them is random too. Aggregate functions turn a random term into one number you can optimize or constrain.

Function What it does
skr.Env('', True) A stochastic environment.
env.normal, lognormal, poisson, binomial, gamma, beta, triangular, … Random terms from common distributions. The last argument is the number of samples.
env.categorical_distribution(weights, values, samples) A discrete distribution you specify.
env.user_distribution(obj, samples), user_vector_distribution Your own sampler, for example an ML model's posterior; the vector version keeps samples of several terms together.
env.aggregate_mean, aggregate_stdev, aggregate_min, aggregate_max Expected value, spread, extremes over the samples.
env.aggregate_quantile(t, p, largest), aggregate_cvar(t, p, largest) Value at risk and conditional value at risk: the p-quantile, or the average of the worst p share.
env.aggregate_relative_frequency_geq(t, x), _leq, _eq Probability that t ≥ x (≤, =). Use with enforce_geq for chance constraints such as service levels.

Example

%pip install -q seekerdemo "setuptools<81"
import seekerdemo as skr

# How many umbrellas to stock for a festival with uncertain demand?
env = skr.Env('', True)                                # True = stochastic environment
demand = env.normal(400, 120, 0, 1e9, 500)             # mean, stdev, low, high, samples
stock = env.ordinal(0, 1000)
profit = 12 * env.min([stock, demand]) - 5 * stock     # random, one value per sample
mean = env.aggregate_mean(profit)
worst = env.aggregate_cvar(profit, 0.1, False)         # average of the worst 10%
sold_out = env.aggregate_relative_frequency_geq(demand - stock, 0)
env.maximize(mean, 2)
print(f"stock {stock.get_value():.0f}: expected profit {mean.get_value():.0f}, "
      f"worst 10% average {worst.get_value():.0f}, sold out in {100*sold_out.get_value():.0f}% of cases")
env.end()
stock 426: expected profit 2233, worst 10% average 190, sold out in 42% of cases

Your own Python code

When part of the problem is easier to write as Python (a price table, a simulation, an existing cost function), wrap it as a term. Seeker calls it with the current values of its inputs.

Function What it does
class X(skr.UserEvalTerm) Implement evaluate(values) returning a number, and incremental() returning False (or True plus inc_evaluate for faster updates).
env.user_defined_term(inputs, obj) A term computed by your object from the input terms. Keep a reference to the object while solving.
class X(skr.UserEvalVector), env.user_defined_vector(inputs, obj, n) The same for functions that return n values (recompute(values) returns a list).
Tip Seeker cannot look inside your function, so it is evaluated as a whole. Use it where it simplifies the model; keep the rest in Seeker terms.

Example

%pip install -q seekerdemo "setuptools<81"
import seekerdemo as skr

# Any Python function can become a term. Here: a supplier price table with
# quantity breaks that would be tedious to write as Seeker expressions.
breaks = [(0, 10.0), (100, 9.2), (250, 8.5), (500, 8.1)]    # (from qty, unit price)
def order_cost(q):
    unit = [p for start, p in breaks if q >= start][-1]
    return q * unit + (60 if q > 0 else 0)                  # plus a delivery fee

class OrderCost(skr.UserEvalTerm):
    def __init__(self):
        super().__init__()
    def evaluate(self, values):                 # values of the input terms
        return order_cost(round(values[0]))
    def incremental(self):
        return False

env = skr.Env('')
q = env.ordinal(0, 800)                         # how many units to order
fn = OrderCost()                                # keep a reference while solving
cost = env.user_defined_term([q], fn)
need = 230                                      # we need 230 units; extra units resell at 6.0
total = cost - 6.0 * env.max_0(q - need)
env.enforce_geq(q, need)
env.minimize(total, 1)
print(f"order {q.get_value():.0f} units, net cost {total.get_value():.2f}")
env.end()
order 250 units, net cost 2065.00