Table logging in Julia


Creation date: 2020-02-19

Tags: julia, constraint-programming

This is kinda part of the constraint solver series => Building a constraint solver in Julia but is useful for more than this and might be its own package (yeah I say that often :D)

There were some changes to the constraint solver which still need a post and I'm not satisfied yet to write a post about it but I should...

What is this topic about?

When I started with Julia and Juniper.jl I needed to write a table logger to display the current status of the solver. By status I mean information like best solution found so far (incumbent) and best bound as well as how long did it run so far.

Basically the table from this:

Logging of Juniper.jl

Now I of course also need this for the constraint solver project but I didn't like the code that much and spoiler alert: I'm not completely satisfied with the current implementation now but I think I should put it out here and maybe some others have ideas on how to improve it.

What I want

I want to easily define the table by giving the header information which includes the type, the width and some other information.

In the solver I want to push something to the table and given some criteria the table should decide whether this is a new row or whether there it's not worth it i.e in the constraint solver there can be a lot of backtracking steps but they can be quite fast for example in the case of solving sudokus. I don't want a line for each step in the backtrack! function. Normally the user wants to get updates to see that it's running or if a new solution was found. Therefore I use the rule that a new line should be added every five seconds or if there is a new solution.

Additionally the type of the column should define some formatting rules which the developer should be able to change.

Some problems

Not too sure how to make it as extensible as possible so this is my try of doing it and I welcome your comments/issues/PRs.

Current implementation

TableLogger.jl
mutable struct TableCol
    id                  :: Symbol
    name                :: String
    type                :: DataType
    width               :: Int
    alignment           :: Symbol # :left, :center, :right
    b_format            :: Bool
end

mutable struct TableEntry{T}
    col_id              :: Symbol
    value               :: T
end

mutable struct TableSetup
    cols                :: Vector{TableCol}
    col_idx             :: Dict{Symbol,Int}
    new_row_criteria    :: Bool
    diff_criteria       :: Dict{Symbol, Any}
    last_row            :: Vector{TableEntry}
end

The table is defined by the columns cols might have some criteria to check if a new row should be added or now which depends on last_row and diff_criteria will be passed to the function of deciding whether a new row will be added. I need the col_idx to know which column is at which position. Maybe that can be done in a different fashion.

TableCol has a type which defines whether some formatting should be used. Each row is a vector of TableEntry which defines the column id id of TableCol and the value. The value type doesn't have to be the same as the column type but is normally advised. I wanted to be able to directly set the incumbent value to "-" if no solution has been found yet. That could be done in formatting as well or having a union of Nothing and T for that.

Furthermore I created some constructor functions but those are boring. They check whether format_table_value exists for the given type i.e

function format_table_value(val::Int, len::Int)
    s_val = string(val)
    if length(s_val) > len
        return val > 0 ? ">>" : "<<"
    end
    return s_val
end

If we have an integer value it will be formatted like this. I also have a function for Float64: function format_table_value(val::Float64, len::Int).

Then we have the function to create the header:

function get_header(table::TableSetup)
    ln = ""
    sum_width = 0
    for col in table.cols
        width     = col.width
        sum_width += width
        padding = width-length(col.name)
        if padding < 2
            padding = 2
            col.width = length(col.name)+2
        end
        ln *= repeat(" ",fld(padding, 2)+1)
        ln *= col.name
        ln *= repeat(" ",cld(padding, 2)+1)
    end
    equals = repeat("=", sum(sum_width)+2*length(table.cols))
    header = "$ln\n$equals"
    return header
end

Here fld and cld which are shorter and faster forms of convert(Int, floor(padding/2)) and c for ceil instead of floor. Additionally I add another space on both sides so that the actual width is actually width+2. Basically some kind of margin.

This creates a header like:

#Open    #Closed       Incumbent           Best Bound      Time [s] 
======================================================================

if

table = TableSetup(
    [
        CS.TableCol(:open_nodes, "#Open", Int, 10, :center),
        CS.TableCol(:closed_nodes, "#Closed", Int, 10, :center),
        CS.TableCol(:incumbent, "Incumbent", Float64, 20, :right),
        CS.TableCol(:best_bound, "Best Bound", Float64, 20, :right),
        CS.TableCol(:duration, "Time [s]", Float64, 10, :center)
    ], 
    Dict(:min_diff_duration=>5.0)
)

Currently I don't use the alignment for the header.

Then the same thing for get_row:

function get_row(table::TableSetup, row::Vector{TableEntry})
    ln = ""
    for c=1:length(table.cols)
        width   = table.cols[c].width
        if isassigned(row, c)
            val     = row[c].value
            if table.cols[c].b_format && isa(val, table.cols[c].type)
                s_val   = format_table_value(val, width)
            else
                s_val   = string(val)
            end
        else
            s_val = "-"
        end
        padding = width-length(s_val)

        if table.cols[c].alignment == :center
            ln *= repeat(" ",fld(padding, 2)+1)
            ln *= s_val
            ln *= repeat(" ",cld(padding, 2)+1)
        elseif table.cols[c].alignment == :left
            ln *= " "
            ln *= s_val
            ln *= repeat(" ",padding+1)
        elseif table.cols[c].alignment == :right
            ln *= repeat(" ",padding+1)
            ln *= s_val
            ln *= " "
        else
            @warn "Only the alignments :left, :right and :center are defined."
        end
    end
    return ln
end

As mentioned before b_format is set if we have the format_table_value function with the right input. That can be done with hasmethod

help?> hasmethod
search: hasmethod

  hasmethod(f, t::Type{<:Tuple}[, kwnames]; world=typemax(UInt)) -> Bool

  Determine whether the given generic function has a method matching the given Tuple of argument types with the upper bound of world age given by world.

  If a tuple of keyword argument names kwnames is provided, this also checks whether the method of f matching t has the given keyword argument names. If the matching method
  accepts a variable number of keyword arguments, e.g. with kwargs..., any names given in kwnames are considered valid. Otherwise the provided names must be a subset of the
  method's keyword arguments.

We should only format it this way if the value in the TableEntry actually has the column type. Otherwise it will be converted to a String.

The last thing we need to do is define a way of how to add something to the table. I thought it might be handy to for the developer/user to write functions like:

push_to_table!(table; open_nodes=12, incumbent=100, closed_nodes=17)

Therefore we work with keyword arguments in push_to_table.

function push_to_table!(table::TableSetup; force=false, kwargs...)
    row = Vector{TableEntry}(undef, length(table.cols))
    for p in kwargs
        col_idx = get(table.col_idx, p.first, 0)
        if col_idx != 0
            row[col_idx] = TableEntry(p.first, p.second)
        end
    end
    if force || !table.new_row_criteria || is_new_row(row, table.last_row, table.diff_criteria) 
        println(get_row(table, row))
        table.last_row = row
    end
    return
end

I also added the possibility to force a new row which seems reasonable and I use it if a new solution was found. If a is_new_row function exists the new_row_criteria field will be true and then it is checked given the new row, last_row and the diff_criteria which is a dictionary.

Ways to improve

Currently I don't know what might be the best way of using your own formatting rules for Ints. I think mine can be a reasonable default but how to use a different one without removing the default function. One way is to define a new Type like

mutable struct MyInt
    value :: Int
end

but that is a bit inconvenient. Otherwise the new function needs to be more specific then the default function i.e I could define function format_table_value(val::Int, len) and you define:

function format_table_value(val::Int, len::Int)

(len::Int) is more specific than len but maybe there is a better way in Julia.

Sometimes for debugging (the printing out style :D) it would be nice to have an extra column called watch or something where you can print out the search space of certain variables without changing code inside the solver. Then the ConstraintSolverModel needs to be added to push_to_table! but I'm not too sure how to do that in the best fashion yet.

Would love to hear from you about ways to improve it and whether this might be helpful for your own project.

If you haven't seen my previous post about the Enigma or missed the video about it: Check it out!

I am thinking about making some videos for some topics now. Check out my YouTube channel: Opensources.

New Constraint Solver post: Linear bounds

Thanks for reading and special thanks to my five patrons!

List of patrons paying more than 4<span>$</span> per month:

Currently I get 12<span>$</span> per Month using Patreon and PayPal when I reach my next goal of 50<span>$</span> per month I'll create a faster blog which will also have a better layout:

If you have ideas => Please contact me!

For a donation of a single dollar per month you get early access to the posts. Try it out at the start of a month and if you don't enjoy it just cancel your subscription before pay day (end of month). Check out Patreon!

Any questions, comments or improvements? Please comment here and/or write me an e-mail o.kroeger@opensourc.es and for code feel free to open a PR/issue on GitHub ConstraintSolver.jl

I'll keep you updated on Twitter OpenSourcES as well as my more personal one: Twitter Wikunia_de



Want to be updated? Consider subscribing on Patreon for free
Subscribe to RSS