Powered by AppSignal & Oban Pro

Invoice matching - logistic regression

logisitic-regression.livemd

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)}
------------------------------------------
""")