The project on your CV,
reverse engineered.
The maritime fleet optimizer is the line on your resume most likely to eat ten minutes of the round, and it is the one you understand least, because a lot of it was generated rather than reasoned through. This page rebuilds it: what each stage computes, which modelling choices were genuinely good, and which four places the code is wrong or fragile. Knowing the flaws is not a liability in that room. Naming them before the interviewer does is the strongest move available to you.
The pitch, before the details
We had thirteen thousand AIS position reports for a few hundred tankers, and a requirement to move about 4.6 million tonnes of bunker fuel a month from Port Hedland to Singapore. The pipeline turns each position report into a fuel burn, rolls those up to a monthly cost per vessel, and then picks a fleet. The selection is a mixed integer program: one binary per vessel, minimise total adjusted cost, subject to enough deadweight tonnage, an average safety score above three, and at least one vessel of each of the eight fuel types. The interesting part is the safety constraint, because an average is a ratio and a ratio is not linear, so it has to be rewritten before a solver will take it.
That last sentence is the hook. It invites exactly the follow-up you most want, and the answer to it is the section on the MILP below. Deliver the pitch, then stop talking and let them choose where to dig.
It separates the two halves of the project cleanly. The first half is a data pipeline, and the honest description of a data pipeline is "I implemented a specification someone else wrote". The second half is a modelling decision that was yours. Spending your airtime on the half that was a decision is what makes the project sound like engineering rather than transcription.
From position reports to activity hours
AIS gives you a vessel identifier, a timestamp, a speed, and two geofence columns saying whether the vessel was inside a port boundary or an anchorage. It does not give you duration, and duration is what fuel burn needs. The pipeline invents it with a per-vessel backward difference.
# src/processing/activity.py
result = df.sort_values(["vessel_id", "timestamp"]).copy()
time_diff = result.groupby("vessel_id")["timestamp"].diff()
result["activity_hours"] = time_diff.dt.total_seconds() / 3600
result["activity_hours"] = result["activity_hours"].fillna(0)
Three lines. Every modelling assumption in the project that is not stated anywhere lives in them.
A vessel transmits at 08:00 while transiting at 14 knots, then its transponder goes quiet and the next report arrives 30 hours later, at 14:00 the following day, again at 14 knots. How many hours of fuel burn does this code charge that vessel, and at what load factor?
Thirty hours, at the load factor implied by the 14-knot reading on the second ping. The difference is backward looking, so the entire gap is attributed to the row that closes it, and that row carries the mode and speed observed at 14:00. If the vessel had actually been at anchor for 29 of those 30 hours, the model has just billed a full day of transit fuel that never happened.
The symmetric failure is at the start. The first
ping of every vessel gets
fillna(0), so it contributes nothing at
all. With a few hundred vessels that is a few
hundred dropped intervals, which is small, but it is
a real bias and it is always in the same direction.
Do not defend it. Say the attribution is unbounded, that you would cap each interval at something like twice the nominal reporting period and treat anything longer as an unobserved gap, and that the right sanity check is to plot the distribution of interval lengths and see how much total fuel sits in the tail. That answer shows you think about data quality, which is most of what a trading desk actually does with a timestamped feed.
The mode classifier, and why its order is safe
The four modes are assigned by writing a default and then
overwriting it in sequence, which is last-match-wins rather than
the first-match-wins you get from numpy.select.
That is worth noticing, because it means priority runs backwards
from what the reading order suggests.
conditions = [
in_anchorage & (speed < 1),
in_port & (speed > 1),
~in_port & (speed >= 1),
]
choices = ["Anchorage", "Maneuver", "Transit"]
result["operating_mode"] = pd.Series("Drifting", index=result.index)
for condition, choice in zip(conditions, choices, strict=False):
result.loc[condition, "operating_mode"] = choice
The three conditions are mutually exclusive, so the
overwrite order cannot change the result. Anchorage needs
speed below 1 and Transit needs speed at or above 1, so
those two can never both fire. Maneuver needs
in_port and Transit needs
~in_port. The code is safe, but only by
accident of the thresholds, and a later edit that widened
any one of them would silently change which mode wins.
Saying that out loud is a code-review instinct, and it is
cheap to demonstrate.
!= "null" tell
The geofence columns are tested with both
.notna() and a comparison against the string
"null". That means the source CSV contains
literal four-character null text in some
rows, which pandas happily reads as a string. It is a
one-line defence against a real dirty-data problem, and it
is exactly the sort of thing an interviewer will point at
and ask "why is that there". The answer is that missing
values arrived in two encodings and the loader normalised
neither, so the check has to handle both.
The cube law, and two normalisations
Three physical facts do all the work here, and each one is a clean thing to be able to derive rather than recite.
Hydrodynamic resistance on a hull rises roughly with the square of speed. Power is force times velocity, so power rises with the cube of speed. Engine load is power as a fraction of maximum rated power, so the load factor at speed v is (v over v_max) cubed. That is why slow steaming saves so much fuel: dropping speed by twenty percent halves the power draw.
# src/processing/load_factor.py
result["max_speed"] = MAX_SPEED_MULTIPLIER * result["vref"] # 1.066 x reference speed
result["load_factor"] = (result["speed_knots"] / result["max_speed"]) ** 3
result["load_factor"] = result["load_factor"].round(2)
# under way but barely moving still burns something
transit_or_maneuver = result["operating_mode"].isin(["Transit", "Maneuver"])
below_floor = result["load_factor"] < LOAD_FACTOR_FLOOR # 0.02
result.loc[transit_or_maneuver & below_floor, "load_factor"] = LOAD_FACTOR_FLOOR
The .round(2) is not cosmetic. Downstream, the
low-load adjustment table is indexed by integer percent, and
rounding to two decimals here is what guarantees
load_factor * 100 lands on a key that exists.
It is an implicit contract between two modules that nothing
in the code states, which is the kind of coupling worth
admitting to.
Normalisation one: fuel energy density
Specific fuel consumption is quoted in grams per kilowatt hour, but that figure is measured on a reference fuel with a lower calorific value of 42.7 megajoules per kilogram. Burn something with less energy per kilogram and you need more kilograms for the same work, so the quoted figure has to be scaled up.
sfc_adjusted = sfc * (REFERENCE_LCV_MJ_PER_KG / lcv_of_actual_fuel)
The ratio is the whole idea. LNG has a higher calorific value than heavy fuel oil, so the ratio is below one and the adjusted consumption falls. Methanol has a much lower one, so the ratio is well above one. If you are asked why a methanol vessel burns so many more tonnes for the same voyage, this single line is the answer.
Normalisation two: low load adjustment
A marine diesel below about twenty percent load is outside its design point. Combustion is cooler and less complete, so emissions per tonne of fuel go up, and by different multiples for each gas. The LLAF table encodes that, indexed by integer load percent from 2 to 20, and anything outside that band defaults to a factor of one.
# src/emissions/calculator.py
in_range = (lf_percent >= 2) & (lf_percent <= 20)
lf_clamped = lf_percent.where(in_range) # out of range becomes NaN
llaf_co2 = lf_clamped.map(llaf_lookup["CO2"]).fillna(1.0) # NaN maps to NaN, then to 1.0
where to blank out the rows you do not want
to look up, map against a Series to do the
lookup vectorised, then fillna to supply the
default. Three vectorised passes instead of an
apply with a conditional inside it. This is
the pandas pattern the round will actually test, and it
shows up on the pandas page too: a lookup against a Series
index is a hash join, an apply is a Python
loop with a function call per row.
The same missing fuel type fails two different ways
Both modules look a fuel type up in a dictionary built from the same spreadsheet sheet. They disagree about what happens when the fuel type is not there.
| Module | Lookup | Missing fuel type gives | Consequence |
|---|---|---|---|
fuel/consumption.py |
.map(lcv_lookup) |
NaN | NaN propagates through fuel, emissions, cost, and into the objective. The vessel is silently unusable. |
emissions/calculator.py |
.get(gas, 0) |
0 | Emissions become zero, so carbon cost becomes zero, so the vessel looks cheaper than it is and the solver prefers it. |
The second is the dangerous one. A silent zero in a cost term does not crash, does not warn, and biases the optimum toward exactly the vessels whose data you were missing. The fix is not to pick one default, it is to validate the join up front: assert that every fuel type appearing in the movements data has a row in the factors workbook, and fail loudly at load time if it does not.
A default value is a decision about what to do when your data is wrong, and it should be made once, at the boundary, not independently in every module that happens to do a lookup. This is the same argument as validating at the edge of a service rather than defensively in every handler.
Four cost terms, one of which is finance
The capital recovery factor, derived
This is the only piece of the project that is finance rather than physics, and it is a genuinely good thing to be able to derive on a whiteboard, because it is the same annuity algebra that sits under a bond or a loan.
You pay P today for a ship you will use for
n years, and you want the equivalent level
annual payment A. The present value of that
annuity has to equal P:
P = A * sum(1 / (1+r)^t for t in 1..n)
= A * (1 - (1+r)^-n) / r
# invert for A, multiply top and bottom by (1+r)^n
A = P * r(1+r)^n / ((1+r)^n - 1)
^^^^^^^^^^^^^^^^^^^^^^^^ this is the capital recovery factor
# src/cost/calculator.py
def _calculate_crf() -> float:
r = DISCOUNT_RATE # 0.08
n = SHIP_LIFETIME_YEARS # 30
factor = (1 + r) ** n
return r * factor / (factor - 1)
s = SALVAGE_RATE * p # you get 10% back at year 30
annual = (p - s) * crf + DISCOUNT_RATE * s # amortise the rest, plus carry on the salvage
monthly = annual / 12
Why + r * s? Because the salvage value is
capital you have tied up in the ship for thirty years and
do not get back until the end. You forgo the return you
could have earned on it, every year, and that forgone
return is r times s. It is an
opportunity cost, not a cash cost. Being able to say that
sentence is worth more in an interview than the formula
itself, because it shows you know what a discount rate
is rather than where it goes.
The ship cost table is parsed by hardcoded row indices
(iloc[2], then rows 4 through 10) and by
column names pandas invented for unlabelled columns
("Unnamed: 2" through
"Unnamed: 5"). Insert one row in that
spreadsheet and every cost in the project is wrong, with
no error. If asked what you would change first, this is a
better answer than anything algorithmic: parse the sheet
by locating its header text, and assert the shape you
expect before you index into it.
Safety enters the model twice: once as a risk premium multiplying the cost, and once as a hard constraint on the fleet average. An interviewer who notices this will ask whether you are double counting. What is the defensible answer?
They are answering different questions, and that is the defence. The premium prices the expected cost of operating a riskier vessel, incidents, insurance, detention, and it belongs in the objective because it is money. The constraint encodes a policy that is not price-sensitive: a regulator or a charterer will not accept a fleet averaging below three no matter how cheap it is. Objectives express preferences, constraints express things that are not for sale.
The honest caveat, which you should volunteer: the two are not calibrated against each other. Nobody checked whether a ten percent premium is the right price for a score-1 vessel, and if it is too high the constraint is never binding while the objective quietly does all the work. The Pareto sweep is actually the tool that would tell you which of the two is driving the answer, and reading it that way is a better use of it than the chart it produces.
The optimisation, and the one trick worth knowing
Strip away the pipeline and the model is four lines of algebra. Learn to write these on a whiteboard without notes, because this is the part of the project that is genuinely yours.
# decision variable: x_i in {0, 1}, one per vessel
minimise sum_i c_i * x_i # adjusted monthly cost
subject to sum_i dwt_i * x_i >= 4,576,667 # the cargo has to fit
sum_i (s_i - T) * x_i >= 0 # average safety >= T (see below)
sum_{i in type f} x_i >= 1 for each fuel type f
Why the safety constraint is written like that
The requirement is that the average safety score of the selected fleet is at least T. Written directly, that is a ratio of two decision-dependent sums, and a solver that only accepts linear expressions will not take it.
# what you want to say, which is not linear:
sum_i s_i * x_i
--------------- >= T
sum_i x_i
# the denominator is strictly positive whenever any vessel is chosen,
# so multiplying both sides by it preserves the direction of the inequality:
sum_i s_i * x_i >= T * sum_i x_i
# collect, and it is linear in x:
sum_i (s_i - T) * x_i >= 0
src/optimization/solver.py, the constraint named
min_avg_safety.
Read the final form as an accounting identity and it stops
feeling like a trick. Each vessel carries a signed surplus
s_i - T. Vessels above the threshold bank
surplus, vessels below it spend surplus, and the fleet is
admissible exactly when the books balance. That reading is
also what tells you how to extend it: any constraint on a
weighted mean linearises the same way, and a constraint on a
ratio of two general sums is the Charnes-Cooper
transformation, which is the same move applied to the whole
program rather than one row.
Multiplying through is only valid because the denominator is positive. The empty fleet has a denominator of zero and satisfies the linear form trivially, so on its own this constraint would permit selecting nothing. It is safe here only because the tonnage constraint forces the fleet to be non-empty. Saying that unprompted is the difference between having applied a trick and having understood it.
Reading the solver code
prob = pulp.LpProblem("Fleet_Selection", pulp.LpMinimize)
x = {vid: pulp.LpVariable(f"x_{vid}", cat=pulp.LpBinary) for vid in vessel_ids}
prob += pulp.lpSum(cost_lookup[vid] * x[vid] for vid in vessel_ids)
prob += (pulp.lpSum(dwt_lookup[vid] * x[vid] for vid in vessel_ids) >= params.min_dwt, "min_dwt")
prob += (pulp.lpSum((safety_lookup[vid] - params.min_avg_safety) * x[vid]
for vid in vessel_ids) >= 0, "min_avg_safety")
Three things about the PuLP idiom are worth being able to
explain. The first += with a bare expression
sets the objective; every later one with an inequality adds
a constraint. Naming each constraint is not decoration, it
is what lets you read the LP file and inspect duals
afterwards. And "each vessel at most once" is not written
anywhere because it is already implied by the variable being
binary, which is the kind of thing an interviewer will check
you noticed.
Floating point on a binary. The selection
is read back with
[vid for vid, var in x.items() if var.varValue == 1]. CBC returns floats, and a variable the solver has fixed
at one can come back as 0.9999999999. That comparison
should be > 0.5. It has probably not
bitten yet, which is exactly why it is worth mentioning:
an exact equality test against a float coming out of a
numerical solver is a bug that waits.
The status is never checked.
_extract_result records
solver_status into the result object but
never branches on it. If the problem is infeasible the
selection is empty, the sums are zero, and the pipeline
cheerfully reports a fleet costing nothing. The
sensitivity grid does check feasibility before using a
result, so the codebase knows the check is necessary and
simply omits it on the main path.
The four analyses, honestly assessed
These are what make the submission look ambitious, and they are also where the generated code is weakest. Each one is a real technique applied with a parameter or a value function that does not quite fit the problem. Go in knowing which is which.
Pareto frontier by epsilon-constraint
The method itself is textbook and correctly applied. To trace a cost-against-safety frontier you do not put both objectives in one function with weights, you fix one as a constraint at a series of levels and minimise the other. Here the safety threshold sweeps 3.0 to 5.0 in steps of 0.1, twenty-one solves, each producing a point.
Weighted sums would have been the wrong choice, and knowing why is a good thing to have ready: a weighted sum can only ever find points on the convex hull of the frontier, so with integer decisions it silently skips every solution sitting in a non-convex dent. The epsilon-constraint method finds those.
The code computes
(cost_k - cost_{k-1}) / step and calls it a
shadow price. In a linear program a shadow price is a dual
variable, the exact rate of change of the optimum with
respect to a right-hand side. In an integer program the
value function is a step function, so there is no
derivative to report. What this number really is, is a
secant across a 0.1 interval, and it will read zero across
a flat stretch and then spike when the optimal fleet jumps
to a different composition. That is still useful
information, it just is not a dual, and calling it one is
the sort of imprecision an interviewer with an operations
research background will land on immediately.
The sweep is
while threshold <= safety_max + 1e-9 with
threshold += step. Twenty additions of 0.1
drift, which is why the epsilon is there. The cleaner
version iterates over integers and divides once:
for k in range(21): threshold = 3.0 + k / 10. Same idea as never accumulating a float clock in a
backtest.
Shapley values, and what they are measuring
The Shapley value of a player is its average marginal contribution across all orderings in which the coalition could have been assembled. With a fleet of any size the number of orderings is factorial, so the code samples: one thousand random permutations, add vessels one at a time, record the change in coalition value at each insertion. Sampling for Shapley is the standard approach and it is implemented correctly.
The problem is the value function. An infeasible coalition
is assigned a penalty of twice the total cost of every
vessel in the dataset, and a feasible one is assigned its
actual cost. Contribution is recorded as
previous - current.
# every permutation looks like this:
step coalition state value contribution recorded
---- --------------- ----- ---------------------
1..k-1 infeasible PENALTY PENALTY - PENALTY = 0
k becomes feasible real cost PENALTY - cost = enormous, positive
k+1..n feasible, growing real cost + more small, NEGATIVE
Almost the entire Shapley value of a vessel comes from how often it happened to be the vessel that tipped the coalition from infeasible to feasible. Once feasibility is reached, every further vessel adds cost, so its recorded contribution is negative. The output is therefore close to a measure of position in random orderings rather than of economic contribution, and it is dominated by an arbitrary penalty constant that nobody tuned.
The fix is a value function that is finite and meaningful on infeasible coalitions. The natural one is cost of the cheapest feasible completion of the coalition, so that adding a useful vessel genuinely reduces the value and adding a redundant one changes nothing. That is more expensive to evaluate, which is a fine thing to say out loud: the honest version costs a solve per evaluation, and that is why the shortcut was taken.
Permutations are drawn only from vessels already in the optimal fleet, not from all vessels. So this is the Shapley value of a game whose player set is the winning coalition. That is a legitimate thing to compute, "how do the chosen vessels share credit among themselves", but it cannot tell you anything about a vessel that was not chosen, which is what most people assume a Shapley attribution is for.
MCMC robustness, and the temperature bug
The intent is good and the structure is right. Start from the
optimal fleet, propose flipping one vessel in or out, accept
downhill moves always and uphill moves with probability
exp(-beta * delta), and count how often each
vessel appears. Vessels present in nearly every near-optimal
fleet are structurally essential; vessels that come and go
are substitutable. That is a Metropolis sampler and it is a
genuinely nice way to ask a robustness question that a
single optimum cannot answer.
beta = 0.0001 # src/optimization/mcmc.py, default
cost_diff = proposed_cost - current_cost # costs are monthly USD, order 1e6
acceptance_prob = 1.0 if cost_diff <= 0 else math.exp(-beta * cost_diff)
Put the numbers in. A modest uphill move of one hundred
thousand dollars gives
exp(-0.0001 * 100000) = exp(-10), about four in
one hundred thousand. A million-dollar move gives
exp(-100), which is zero for every practical
purpose.
With uphill moves effectively never accepted, the chain is not sampling the Boltzmann distribution over near-optimal fleets. It is doing randomised hill descent from a starting point that is already the optimum, so it barely moves. Every vessel therefore appears in nearly every iteration, every appearance frequency comes out near 1.0, and the categories the code assigns, essential above 0.9 and variable below 0.5, collapse to "everything is essential". The analysis is structurally incapable of producing the finding it was written to look for.
Two smaller issues compound it. There is no burn-in discard, so the initial state is counted as a sample. And because the chain starts at the optimum, the early iterations are the most biased ones and they are included in full.
Beta has units, and they are inverse dollars. It has to be
set from the cost scale of the problem rather than picked
as a round number: choose beta so that
beta * (typical uphill move) is order one,
which here means beta around 1e-6 to 1e-5, or equivalently
normalise costs by the optimal total before sampling. The
general principle carries well beyond this project. Any
time you exponentiate a quantity that carries units, you
have implicitly chosen a scale, and if you did not choose
it deliberately you chose it wrong.
Sensitivity grid
The least glamorous and the most trustworthy of the four.
Recompute carbon cost at four prices, re-solve at five
safety thresholds, report a twenty-cell grid of optimal
costs and fleet sizes, and mark cells where the solver did
not reach optimality as infeasible. It checks
solver_status before using the result, which is
the check the main path forgot. If you only have time to
defend one of the four extras, defend this one.
The questions, and how to answer them
Use the forty-second pitch at the top of this page. Problem, data, two-stage structure, then the hook about the non-linear constraint. Do not narrate the module list. A module list tells them what files exist, which is the least interesting fact about any codebase.
Because the problem is small enough that optimality is free. A few hundred binaries with four constraint families is nothing for a branch-and-bound solver, and in exchange you get a proof of optimality and a feasibility answer rather than a guess.
Then name the structure honestly: this is close to a multi-dimensional knapsack, which is NP-hard in general, so the fact that it solves instantly is a statement about this instance and not about the class. If the fleet were a hundred thousand vessels, or if the constraints coupled vessels to each other rather than only to fleet totals, a greedy approach with a bound would start to look reasonable.
This is the question the project answers worst, and the one a trading desk cares about most. There are no tests. Say so, then say what you would write, in this order:
Boundary assertions at load. Every fuel type in the movements data has a row in the factors workbook. Every vessel has a positive deadweight tonnage and a safety score in 1 to 5. Load factors land in [0, 1].
One hand-computed vessel. Take a single vessel, do the fuel and emissions arithmetic in a spreadsheet, and assert the pipeline reproduces it. This catches unit errors, which are the most common failure in a calculation like this and the least likely to be caught by anything else.
Properties of the optimum. The returned fleet satisfies every constraint. Relaxing the safety threshold can never raise the optimal cost. Doubling the carbon price can never reduce the fleet's total emissions. These are monotonicity properties you can assert without knowing the right answer, which is exactly what makes them worth having.
Do not say the solver. Say the attribution of time to position reports, because it is true and because it is the interesting problem. AIS gives you instants and the model needs intervals, every scheme for turning one into the other is an assumption, and the fuel numbers are more sensitive to that assumption than to anything in the optimisation. That answer generalises straight to market data, where the same question is how you attribute state between ticks, and an interviewer on a trading desk will hear the parallel.
Answer it plainly. It was a hackathon, a lot of the scaffolding was generated, and the parts you own are the model formulation and the decisions about what to compute. Then demonstrate ownership instead of claiming it, by naming a flaw the interviewer has not found yet. The temperature in the MCMC sampler is the best one to use: it is a real error, it takes thirty seconds to explain, and understanding why it is wrong requires understanding what the sampler was supposed to do.
Nobody in that room expects a hackathon repository to be production code. What they are testing is whether you can tell the difference, and a candidate who volunteers a specific bug with a specific fix reads as far more credible than one who defends everything.
The five-minute refresh
| Have ready | In one line |
|---|---|
| The linearisation |
Average is a ratio, multiply by the positive
denominator, collect into
sum (s_i - T) x_i >= 0
|
| The cube law | Resistance goes as speed squared, power is force times velocity, so load goes as speed cubed |
| The CRF | Annuity that has present value equal to the purchase price, plus carry on the salvage you do not get back until year thirty |
| Epsilon-constraint | Weighted sums only reach the convex hull; fixing one objective as a constraint reaches the dents too |
| The bug you volunteer | Beta is in inverse dollars and was set to 1e-4 against million-dollar moves, so no uphill move is ever accepted |
| The bug you concede |
varValue == 1 on a float from CBC, and
solver_status is recorded but never
checked
|
| The thing you would fix first | No tests, and a spreadsheet parsed by hardcoded row index |