Invoice matching - logistic regression
System.put_env([
{"AWS_ACCESS_KEY_ID", "your_access_key_id"},
{"AWS_SECRET_ACCESS_KEY", "your_secret_access_key"},
{"GO_LIMITLESS_SECRET_ID", "your_secret_id"},
{"GO_LIMITLESS_SECRET_KEY", "your_secret_key"},
{"OPENAI_API_KEY", "your_openai_api_key"},
{"REDUCTO_API_KEY", "your_reducto_api_key"}
])
Mix.install(
[
{:firmowid, path: Path.join(__DIR__, "../../../../.."), env: :dev},
{:kino, "~> 0.13.0"},
{:scholar, "~> 0.4.0"}
],
config_path: :firmowid,
lockfile: :firmowid,
start_applications: false
)
# boot only what the notebook really needs
Application.ensure_all_started(:ecto_sql)
# ← this is the missing bit
Application.ensure_all_started(:kino)
# now you can attach the repo as a Livebook child
{:ok, _pid} = Kino.start_child({Firmowid.Repo, []})
setup
defmodule CostInvoiceMatch do
use Ecto.Schema
@primary_key false
embedded_schema do
field :transaction_id, :string
field :cost_invoice_id, :string
end
end
defmodule SalesInvoiceMatch do
use Ecto.Schema
@primary_key false
embedded_schema do
field :transaction_id, :string
field :sales_invoice_id, :string
end
end
defmodule Matches do
@moduledoc """
Provides functions to read, write, add, and delete invoice matches and skipped invoices from matches.json.
"""
@json_path Path.join(__DIR__, "matches.json") |> Path.expand()
@doc """
Reads the current state from matches.json.
Returns a map with keys :cost_invoice_matches, :sales_invoice_matches, :skipped_cost_invoices, :skipped_sales_invoices.
"""
def read_state do
raw = @json_path |> File.read!() |> Jason.decode!(keys: :atoms)
cost_matches =
Enum.map(raw.cost_invoice_matches, fn attrs ->
changeset = Ecto.Changeset.change(%CostInvoiceMatch{}, attrs)
if changeset.valid? do
Ecto.Changeset.apply_changes(changeset)
else
raise "Invalid cost_invoice_match: #{inspect(attrs)} errors: #{inspect(changeset.errors)}"
end
end)
sales_matches =
Enum.map(raw.sales_invoice_matches, fn attrs ->
changeset = Ecto.Changeset.change(%SalesInvoiceMatch{}, attrs)
if changeset.valid? do
Ecto.Changeset.apply_changes(changeset)
else
raise "Invalid sales_invoice_match: #{inspect(attrs)} errors: #{inspect(changeset.errors)}"
end
end)
%{
cost_invoice_matches: cost_matches,
sales_invoice_matches: sales_matches,
skipped_cost_invoices: Map.get(raw, :skipped_cost_invoices, []),
skipped_sales_invoices: Map.get(raw, :skipped_sales_invoices, [])
}
end
@doc """
Writes the given state map to matches.json.
The state should be a map with keys :cost_invoice_matches, :sales_invoice_matches, :skipped_cost_invoices, :skipped_sales_invoices.
"""
def write_state(state) when is_map(state) do
normalized_state =
state
|> Map.update!(:cost_invoice_matches, fn list -> Enum.map(list, &Map.from_struct/1) end)
|> Map.update!(:sales_invoice_matches, fn list -> Enum.map(list, &Map.from_struct/1) end)
|> Map.update(:skipped_cost_invoices, [], fn list -> list || [] end)
|> Map.update(:skipped_sales_invoices, [], fn list -> list || [] end)
json = Jason.encode!(normalized_state, pretty: true)
File.write!(@json_path, json)
end
@doc """
Adds a cost invoice match. Throws if the match already exists.
Accepts a map or %CostInvoiceMatch{}.
"""
def add_cost_invoice_match(%CostInvoiceMatch{} = new_match) do
state = read_state()
if Enum.any?(state.cost_invoice_matches, &(&1.cost_invoice_id == new_match.cost_invoice_id)) do
raise "Cost invoice match already exists"
end
updated = state.cost_invoice_matches ++ [new_match]
write_state(%{state | cost_invoice_matches: updated})
end
@doc """
Adds a sales invoice match. Throws if the match already exists.
Accepts a map or %SalesInvoiceMatch{}.
"""
def add_sales_invoice_match(%SalesInvoiceMatch{} = match),
do: add_sales_invoice_match(Map.from_struct(match))
def add_sales_invoice_match(%{transaction_id: _tid, sales_invoice_id: sid} = attrs) do
state = read_state()
if Enum.any?(state.sales_invoice_matches, &(&1.sales_invoice_id == sid)) do
raise "Sales invoice match already exists"
end
changeset = Ecto.Changeset.change(%SalesInvoiceMatch{}, attrs)
if not changeset.valid? do
raise "Invalid sales invoice match: #{inspect(changeset.errors)}"
end
updated = state.sales_invoice_matches ++ [Ecto.Changeset.apply_changes(changeset)]
write_state(%{state | sales_invoice_matches: updated})
end
@doc """
Deletes a cost invoice match by transaction_id and cost_invoice_id.
"""
def delete_cost_invoice_match(transaction_id, cost_invoice_id) do
state = read_state()
updated =
Enum.reject(
state.cost_invoice_matches,
&(&1.transaction_id == transaction_id and &1.cost_invoice_id == cost_invoice_id)
)
write_state(%{state | cost_invoice_matches: updated})
end
@doc """
Deletes a sales invoice match by transaction_id and sales_invoice_id.
"""
def delete_sales_invoice_match(transaction_id, sales_invoice_id) do
state = read_state()
updated =
Enum.reject(
state.sales_invoice_matches,
&(&1.transaction_id == transaction_id and &1.sales_invoice_id == sales_invoice_id)
)
write_state(%{state | sales_invoice_matches: updated})
end
# Skipped cost invoices
def add_skipped_cost_invoice(invoice_id) do
state = read_state()
updated = Enum.uniq([invoice_id | state.skipped_cost_invoices])
write_state(%{state | skipped_cost_invoices: updated})
end
def remove_skipped_cost_invoice(invoice_id) do
state = read_state()
updated = Enum.reject(state.skipped_cost_invoices, &(&1 == invoice_id))
write_state(%{state | skipped_cost_invoices: updated})
end
# Skipped sales invoices
def add_skipped_sales_invoice(invoice_id) do
state = read_state()
updated = Enum.uniq([invoice_id | state.skipped_sales_invoices])
write_state(%{state | skipped_sales_invoices: updated})
end
def remove_skipped_sales_invoice(invoice_id) do
state = read_state()
updated = Enum.reject(state.skipped_sales_invoices, &(&1 == invoice_id))
write_state(%{state | skipped_sales_invoices: updated})
end
end
# NOTE: put_org_id has been removed. Use tenant: org_id on Ash calls instead.
org_id = "01948dfc-1367-7e4f-90e2-54b685167481"
cost_invoices = Firmowid.CostInvoices.list_cost_invoices(~D[2024-01-01], ~D[2030-12-30])
transactions = Firmowid.Finances.list_transactions(~D[2024-01-01], ~D[2030-12-30])
sales_invoices =
Firmowid.SalesInvoices.list_sales_invoices(~D[2024-01-01], ~D[2030-12-30])
|> Enum.map(fn inv ->
Firmowid.Repo.preload(inv, :sales_invoice_items)
end)
IO.inspect(length(cost_invoices), label: "cost_invoices")
IO.inspect(length(sales_invoices), label: "sales_invoices")
IO.inspect(length(transactions), label: "transactions")
length(cost_invoices) + length(sales_invoices)
invoice <-> transaction labeling
matches = Matches.read_state()
cost_invoices_to_match =
cost_invoices
|> Enum.filter(fn cost_invoice ->
not Enum.any?(matches.cost_invoice_matches, fn match ->
match.cost_invoice_id == cost_invoice.id
end) and
not Enum.member?(matches.skipped_cost_invoices, cost_invoice.id)
end)
current_index =
Kino.Input.number("Current Invoice Index",
default: 1,
min: 1,
max: max(length(cost_invoices_to_match), 1)
)
output_frame = Kino.Frame.new()
display_current_invoice = fn index ->
# index is now 1-based from the input, so convert to 0-based for list access
zero_index = index - 1
if zero_index >= 0 and zero_index < length(cost_invoices_to_match) do
current_invoice = Enum.at(cost_invoices_to_match, zero_index)
prefiltered_transactions =
Firmowid.Invoicing.Matching.Windowing.pre_filter_invoice_transactions(
current_invoice,
transactions
)
invoice_info = """
## Cost Invoice #{index} of #{length(cost_invoices_to_match)}
- Invoice identifier: #{current_invoice.invoice_identifier}
- Date: #{current_invoice.issue_date}
- Amount: #{current_invoice.total_amount}
- Currency: #{current_invoice.currency}
- Seller: #{current_invoice.seller_display_name}
- Seller bank account: #{current_invoice.account_number}
- Description: #{current_invoice.description || "No description"}
"""
skip_button = Kino.Control.button("Skip this invoice")
Kino.listen(skip_button, fn _event ->
try do
Matches.add_skipped_cost_invoice(current_invoice.id)
Kino.Frame.render(output_frame, Kino.Markdown.new("⏭️ Skipped this invoice!"))
rescue
e ->
Kino.Frame.render(output_frame, Kino.Markdown.new("❌ Error: #{Exception.message(e)}"))
end
end)
if length(prefiltered_transactions) > 0 do
transaction_blocks =
prefiltered_transactions
|> Enum.with_index(1)
|> Enum.map(fn {transaction, idx} ->
params =
Firmowid.Invoicing.Matching.ParametrizedResult.generate_parametrized_result(
current_invoice,
transaction
)
details = """
**#{idx}. Transaction**
- Date: #{transaction.booking_date}
- Amount: #{transaction.transaction_amount}
- Currency: #{transaction.transaction_currency}
- Creditor: #{transaction.creditor_name}
- Bank account: #{transaction.creditor_account}
- Description: #{transaction.remittance_information_unstructured || "No description"}
**Calculated Params:**
```elixir
#{inspect(params, pretty: true)}
```
"""
match_button = Kino.Control.button("Match transaction ##{idx}")
Kino.listen(match_button, fn _event ->
try do
Matches.add_cost_invoice_match(%CostInvoiceMatch{
transaction_id: transaction.id,
cost_invoice_id: current_invoice.id
})
Kino.Frame.render(output_frame, Kino.Markdown.new("✅ Matched transaction ##{idx}!"))
rescue
e ->
Kino.Frame.render(
output_frame,
Kino.Markdown.new("❌ Error: #{Exception.message(e)}")
)
end
end)
[Kino.Markdown.new(details), match_button]
end)
|> List.flatten()
Kino.Frame.render(
output_frame,
Kino.Layout.grid(
[
Kino.Markdown.new(invoice_info),
skip_button
| transaction_blocks
],
columns: 1
)
)
else
Kino.Frame.render(
output_frame,
Kino.Layout.grid(
[
Kino.Markdown.new(invoice_info <> "\n\n### No prefiltered transactions found"),
skip_button
],
columns: 1
)
)
end
else
Kino.Frame.render(
output_frame,
Kino.Markdown.new("**Invalid index. Please select a valid invoice.**")
)
end
end
Kino.listen(current_index, fn %{value: index} ->
display_current_invoice.(index)
end)
display_current_invoice.(1)
Kino.Layout.grid(
[current_index, output_frame],
columns: 1
)
matches = Matches.read_state()
sales_invoices_to_match =
sales_invoices
|> Enum.filter(fn sales_invoice ->
not Enum.any?(
matches.sales_invoice_matches,
fn match -> match.sales_invoice_id == sales_invoice.id end
) and
not Enum.member?(matches.skipped_sales_invoices, sales_invoice.id)
end)
current_index =
Kino.Input.number("Current Sales Invoice Index",
default: 1,
min: 1,
max: max(length(sales_invoices_to_match), 1)
)
output_frame = Kino.Frame.new()
display_current_invoice = fn index ->
zero_index = index - 1
if zero_index >= 0 and zero_index < length(sales_invoices_to_match) do
current_invoice = Enum.at(sales_invoices_to_match, zero_index)
prefiltered_transactions =
Firmowid.Invoicing.Matching.Windowing.pre_filter_invoice_transactions(
current_invoice,
transactions
)
invoice_info = """
## Sales Invoice #{index} of #{length(sales_invoices_to_match)}
- Date: #{current_invoice.issue_date}
- Amount: #{current_invoice.gross_value}
- Currency: #{current_invoice.currency}
- Buyer: #{current_invoice.buyer_display_name}
- Seller bank account: #{current_invoice.seller_account_number}
"""
skip_button = Kino.Control.button("Skip this invoice")
Kino.listen(skip_button, fn _event ->
try do
Matches.add_skipped_sales_invoice(current_invoice.id)
Kino.Frame.render(output_frame, Kino.Markdown.new("⏭️ Skipped this invoice!"))
rescue
e -> Kino.Frame.render(output_frame, Kino.Markdown.new("❌ Error: #{Exception.message(e)}"))
end
end)
if length(prefiltered_transactions) > 0 do
transaction_blocks =
prefiltered_transactions
|> Enum.with_index(1)
|> Enum.map(fn {transaction, idx} ->
params =
Firmowid.Invoicing.Matching.ParametrizedResult.generate_parametrized_result(
current_invoice,
transaction
)
details = """
**#{idx}. Transaction**
- Date: #{transaction.booking_date}
- Amount: #{transaction.transaction_amount}
- Currency: #{transaction.transaction_currency}
- Debtor: #{transaction.debtor_name}
- Bank account: #{transaction.debtor_account}
- Description: #{transaction.remittance_information_unstructured || "No description"}
**Calculated Params:**
```elixir
#{inspect(params, pretty: true)}
```
"""
match_button = Kino.Control.button("Match transaction ##{idx}")
Kino.listen(match_button, fn _event ->
try do
Matches.add_sales_invoice_match(%{
transaction_id: transaction.id,
sales_invoice_id: current_invoice.id
})
Kino.Frame.render(output_frame, Kino.Markdown.new("✅ Matched transaction ##{idx}!"))
rescue
e ->
Kino.Frame.render(
output_frame,
Kino.Markdown.new("❌ Error: #{Exception.message(e)}")
)
end
end)
[Kino.Markdown.new(details), match_button]
end)
|> List.flatten()
Kino.Frame.render(
output_frame,
Kino.Layout.grid(
[
Kino.Markdown.new(invoice_info),
skip_button
| transaction_blocks
],
columns: 1
)
)
else
Kino.Frame.render(
output_frame,
Kino.Layout.grid(
[
Kino.Markdown.new(invoice_info <> "\n\n### No prefiltered transactions found"),
skip_button
],
columns: 1
)
)
end
else
Kino.Frame.render(
output_frame,
Kino.Markdown.new("**Invalid index. Please select a valid invoice.**")
)
end
end
Kino.listen(current_index, fn %{value: index} ->
display_current_invoice.(index)
end)
display_current_invoice.(1)
Kino.Layout.grid(
[current_index, output_frame],
columns: 1
)
evaluate the model
Right now our model is a simple Logisitic Regression classifier. Predicts yes or no on an invoice match.
Works okay for simple invoices (PLN -> PLN, matching names, matching dates).
It is incapable of doing many-to-many match. Something to think about in the future.
matches = Matches.read_state()
cost_invoice_data =
matches.cost_invoice_matches
|> Enum.reject(fn %{cost_invoice_id: id} -> Enum.member?(matches.skipped_cost_invoices, id) end)
|> Enum.map(fn %{cost_invoice_id: cost_invoice_id} = a_match ->
cost_invoice = cost_invoices |> Enum.find(&(&1.id == cost_invoice_id))
Map.merge(%{invoice: cost_invoice, invoice_type: :cost}, a_match)
end)
|> Enum.reject(fn %{invoice: invoice} -> is_nil(invoice) end)
|> Enum.flat_map(fn %{
invoice: invoice,
cost_invoice_id: invoice_id,
transaction_id: transaction_id
} ->
Firmowid.Invoicing.Matching.Windowing.pre_filter_invoice_transactions(
invoice,
transactions
)
|> Enum.map(fn result ->
%{
invoice_type: :cost,
invoice_id: invoice_id,
transaction_id: result.id,
match: result.id == transaction_id
}
end)
end)
sales_invoice_data =
matches.sales_invoice_matches
|> Enum.reject(fn %{sales_invoice_id: id} ->
Enum.member?(matches.skipped_sales_invoices, id)
end)
|> Enum.map(fn %{sales_invoice_id: sales_invoice_id} = a_match ->
sales_invoice = sales_invoices |> Enum.find(&(&1.id == sales_invoice_id))
Map.merge(%{invoice: sales_invoice, invoice_type: :sales}, a_match)
end)
|> Enum.flat_map(fn %{
invoice: invoice,
sales_invoice_id: invoice_id,
transaction_id: transaction_id
} ->
Firmowid.Invoicing.Matching.Windowing.pre_filter_invoice_transactions(
invoice,
transactions
)
|> Enum.map(fn result ->
%{
invoice_type: :sales,
invoice_id: invoice_id,
transaction_id: result.id,
match: result.id == transaction_id
}
end)
end)
all_data = cost_invoice_data ++ sales_invoice_data
IO.puts(
"prepared #{length(all_data)} pairs (cost: #{length(cost_invoice_data)}, sales: #{length(sales_invoice_data)})\n"
)
IO.puts(
"good: #{length(all_data |> Enum.filter(& &1.match))} (cost: #{length(cost_invoice_data |> Enum.filter(& &1.match))}, sales: #{length(sales_invoice_data |> Enum.filter(& &1.match))})"
)
IO.puts(
"bad: #{length(all_data |> Enum.filter(&(!&1.match)))} (cost: #{length(cost_invoice_data |> Enum.filter(&(!&1.match)))}, sales: #{length(sales_invoice_data |> Enum.filter(&(!&1.match)))})"
)
# ---------- build feature vectors ------------------------------------------------
rows =
all_data
|> Enum.map(fn %{invoice_type: type, invoice_id: iid, transaction_id: tid, match: match} ->
invoice =
case type do
:cost -> Enum.find(cost_invoices, &(&1.id == iid))
:sales -> Enum.find(sales_invoices, &(&1.id == iid))
end
transaction = Enum.find(transactions, &(&1.id == tid))
params =
Firmowid.Invoicing.Matching.ParametrizedResult.generate_parametrized_result(
invoice,
transaction
)
# ─── assemble one feature vector ───
features = [
# Day-lag buckets (already 0/1 from ParametrizedResult)
params.days_lag_le_3,
params.days_lag_le_7,
params.days_lag_le_30,
params.days_lag_gt_30,
# Signed amount ratio, clip to [-5, +5] to tame outliers
params.signed_amount_match,
# Relative amount difference, cap at 5 for safety
params.relative_amount_difference,
# String / account similarities
params.transaction_side_similarity,
params.bank_account_similarity,
# Currency match
params.is_same_currency,
# Signals from remittance text
params.amount_present_in_remittance_information_unstructured,
params.invoice_identifier_present_in_remittance_information_unstructured
]
{iid, features ++ [if(match, do: 1, else: 0)]}
end)
# ---------- group so one invoice never straddles train & test --------------------
rows_by_invoice = Enum.group_by(rows, &elem(&1, 0), &elem(&1, 1))
# ---------- helper to turn a flat list into tensors ------------------------------
# 11 feature columns → indices 0‥10
make_xy = fn vecs ->
{x_list, y_list} =
vecs
|> Enum.map(fn v -> {Enum.slice(v, 0..10), List.last(v)} end)
|> Enum.unzip()
{
Nx.tensor(x_list, type: {:f, 32}),
Nx.tensor(y_list, type: {:u, 8})
}
end
There are two ways we use this model:
- automated matching in a background job
- UI with suggested transactions
For these two we have different criterias - we never want automated matching to provide us with a false positive.
In the UI though, we want to suggest likely candidates. For values that are good visual indicators, you can run this code and get some inspiration.
# ───────── configurable params ─────────────────────────────
precision_target = 0.90
n_folds = 5
threshold_grid = Enum.map(0..1000, & &1 / 1000) # 0.000 … 1.000 (finer grid)
# ───────────────────────────────────────────────────────────
calc_stats = fn preds, y_bool ->
tp = Nx.logical_and(preds, y_bool) |> Nx.sum() |> Nx.to_number()
fp = Nx.logical_and(preds, Nx.logical_not(y_bool)) |> Nx.sum() |> Nx.to_number()
fn_ = Nx.logical_and(Nx.logical_not(preds), y_bool) |> Nx.sum() |> Nx.to_number()
prec = tp / max(tp + fp, 1)
rec = tp / max(tp + fn_, 1)
{prec, rec}
end
# ───── split invoices once into n_folds groups ─────
invoice_ids = Map.keys(rows_by_invoice) |> Enum.shuffle()
fold_size = Float.ceil(length(invoice_ids) / n_folds) |> trunc()
fold_groups = Enum.chunk_every(invoice_ids, fold_size)
# ───── run CV ─────
fold_results =
for {test_ids, fold_idx} <- Enum.with_index(fold_groups, 1) do
train_ids = fold_groups |> List.delete(test_ids) |> List.flatten()
train_rows = Enum.flat_map(train_ids, &rows_by_invoice[&1])
test_rows = Enum.flat_map(test_ids, &rows_by_invoice[&1])
# Build a parallel list of the original all_data entries for each test row
test_data_entries = Enum.flat_map(test_ids, fn iid ->
# For each test row, find the corresponding all_data entry
# rows_by_invoice[&1] is a list of feature vectors for this invoice
# all_data contains all pairs, so filter by invoice_id
Enum.filter(all_data, fn d -> d.invoice_id == iid end)
end)
{train_x, train_y} = make_xy.(train_rows)
{test_x, test_y} = make_xy.(test_rows)
y_bool = Nx.equal(test_y, 1)
# 1. fit
model = Scholar.Linear.LogisticRegression.fit(train_x, train_y, num_classes: 2)
# 2. P(class = 1)
probs =
model
|> Scholar.Linear.LogisticRegression.predict_probability(test_x)
|> then(&Nx.slice(&1, [0, 1], [Nx.axis_size(test_y, 0), 1]))
|> Nx.reshape({Nx.axis_size(test_y, 0)})
# 3. evaluate thresholds
thr_stats =
for thr <- threshold_grid do
preds = Nx.greater_equal(probs, thr)
{prec, rec} = calc_stats.(preds, y_bool)
{thr, prec, rec}
end
{best_thr, best_prec, best_rec} =
case Enum.find(thr_stats, fn {_, p, _} -> p >= precision_target end) do
nil -> Enum.max_by(thr_stats, fn {_, p, _} -> p end)
hit -> hit
end
# 4. highest score seen on a negative (for “no-FP” cut-off) + show the example(s)
negatives =
probs
|> Nx.to_flat_list()
|> Enum.zip(Nx.to_flat_list(y_bool))
|> Enum.with_index()
|> Enum.filter(fn {{_p, y}, _idx} -> y == 0 end)
max_neg =
negatives
|> Enum.map(fn {{p, _}, _} -> p end)
|> Enum.max(fn -> 0.0 end)
max_neg_examples =
negatives
|> Enum.filter(fn {{p, _}, _} -> p == max_neg end)
|> Enum.map(fn {{_p, _}, idx} -> idx end)
IO.puts("\nHighest-scoring false positive(s) for fold #{fold_idx} (score: #{Float.round(max_neg, 4)}):")
# for idx <- max_neg_examples do
# entry = Enum.at(test_data_entries, idx)
# invoice =
# case entry.invoice_type do
# :cost -> Enum.find(cost_invoices, &(&1.id == entry.invoice_id))
# :sales -> Enum.find(sales_invoices, &(&1.id == entry.invoice_id))
# end
# transaction = Enum.find(transactions, &(&1.id == entry.transaction_id))
# IO.puts("\nInvoice:")
# IO.inspect(invoice)
# IO.puts("Transaction:")
# IO.inspect(transaction)
# IO.puts("Match label: #{entry.match}")
# end
%{
fold: fold_idx,
threshold: best_thr,
precision: best_prec,
recall: best_rec,
max_neg: max_neg
}
end
IO.inspect(fold_results, label: "per fold")
# ───── 1️⃣ FP-free threshold for automatching ─────
safe_thr =
fold_results
|> Enum.map(& &1.max_neg)
|> Enum.max()
|> Float.round(4) # keep a few decimals
IO.puts("\n🛡️ FP-free threshold (autonomous matcher): #{safe_thr}")
# ───── 2️⃣ Relaxed tiers for the UI ─────
ui_ths = fold_results |> Enum.map(& &1.threshold) |> Enum.sort()
thr_hi = Enum.at(ui_ths, ceil(0.9 * n_folds) - 1) # 90-th pct
thr_med = Enum.at(ui_ths, div(n_folds, 2)) # median
worst_prec =
fold_results
|> Enum.map(& &1.precision)
|> Enum.min()
|> then(&Float.round(&1 * 100, 1))
IO.puts("""
\nSuggested UI cut-offs
───────────────
• HIGH ≥ #{Float.round(thr_hi, 2)} (≈ #{worst_prec} % precision)
• MEDIUM ≥ #{Float.round(thr_med, 2)}
• LOW < #{Float.round(thr_med, 2)}
""")
train the model
Train on all available data (labelled pairs) and emit ready-to-paste constants.
Use them in lib/firmowid/invoicing/matching/regression_predictor.ex.
# 0. Helper: vector list → {X, y} tensors
make_xy = fn vecs ->
n_features = length(List.first(vecs)) - 1
{x_rows, y_rows} =
Enum.map(vecs, fn v -> {Enum.take(v, n_features), List.last(v)} end)
|> Enum.unzip()
{
Nx.tensor(x_rows, type: {:f, 32}),
Nx.tensor(y_rows, type: {:u, 8})
}
end
# 1. Flatten data from invoices
all_rows =
rows_by_invoice
|> Map.values()
|> Enum.concat()
# 2. Full training set
{full_x, full_y} = make_xy.(all_rows)
# 3. Fit logistic regression
model = Scholar.Linear.LogisticRegression.fit(full_x, full_y, num_classes: 2)
# 4. Extract class 1 (match) coefficients and bias
n_features = Nx.axis_size(model.coefficients, 0)
coeff_vec =
model.coefficients
|> Nx.slice([0, 1], [n_features, 1])
|> Nx.reshape({n_features})
bias_val =
model.bias
|> Nx.to_flat_list()
|> Enum.at(1)
# 5. Print production-ready literals
coeff_literal =
coeff_vec
|> Nx.to_flat_list()
|> Enum.map(&Float.round(&1, 6))
|> then(&"Nx.tensor(" <> inspect(&1, limit: :infinity) <> ", type: {:f, 32})")
IO.puts("""
------------------------------------------
Copy-paste into your module:
@coefficients #{coeff_literal}
@bias #{Float.round(bias_val, 6)}
------------------------------------------
""")