Choreo.DecisionTree.Analysis (Choreo v0.10.0)

Copy Markdown View Source

Analysis functions for Choreo.DecisionTree.

Provides path enumeration, evaluation, rule extraction, test-case generation, completeness checks, depth metrics, and pruning.

Further reading

Summary

Functions

Returns the number of leaf / outcome nodes.

Evaluates the decision tree against a map of feature values.

Returns the maximum depth of the tree (number of edges from root to deepest leaf).

Returns a map of feature frequencies across all decision nodes.

Generates feature maps that cover every reachable leaf path.

Finds logically impossible paths where a feature is checked against mutually exclusive conditions.

Finds decision nodes whose outgoing branches do not cover an expected set of feature values.

Returns nodes that are not reachable from the root.

Enumerates all root-to-leaf paths.

Returns all root-to-leaf paths with their branch conditions.

Prunes redundant decision nodes.

Returns the unique set of all possible outcome classes the tree can produce.

Extracts IF-THEN rules from the decision tree.

Validates tree completeness.

Functions

breadth(tree)

@spec breadth(Choreo.DecisionTree.t()) :: non_neg_integer()

Returns the number of leaf / outcome nodes.

Examples

iex> tree = Choreo.DecisionTree.new()
iex> tree = tree
...>   |> Choreo.DecisionTree.set_root(:color, feature: "color")
...>   |> Choreo.DecisionTree.add_outcome(:stop)
...>   |> Choreo.DecisionTree.add_outcome(:go)
...>   |> Choreo.DecisionTree.add_outcome(:caution)
...>   |> Choreo.DecisionTree.branch(:color, :stop, "red")
...>   |> Choreo.DecisionTree.branch(:color, :go, "green")
...>   |> Choreo.DecisionTree.branch(:color, :caution, "yellow")
iex> Choreo.DecisionTree.Analysis.breadth(tree)
3

This analysis answers the question: "How many leaf outcomes exist?"

decide(tree, features)

@spec decide(Choreo.DecisionTree.t(), %{required(String.t()) => String.t()}) ::
  {:ok, [Yog.node_id()], String.t()} | {:error, String.t()}

Evaluates the decision tree against a map of feature values.

Walks from the root, at each decision node reading the corresponding feature value and following the branch whose condition matches.

Returns {:ok, path, outcome_label} or {:error, reason}.

Examples

iex> tree = Choreo.DecisionTree.new()
iex> tree = tree
...>   |> Choreo.DecisionTree.set_root(:color, feature: "color")
...>   |> Choreo.DecisionTree.add_outcome(:stop, label: "Stop")
...>   |> Choreo.DecisionTree.add_outcome(:go, label: "Go")
...>   |> Choreo.DecisionTree.branch(:color, :stop, "red")
...>   |> Choreo.DecisionTree.branch(:color, :go, "green")
iex> Choreo.DecisionTree.Analysis.decide(tree, %{"color" => "red"})
{:ok, [:color, :stop], "Stop"}
iex> Choreo.DecisionTree.Analysis.decide(tree, %{"color" => "blue"})
{:error, "No branch for 'blue' from node :color"}
iex> Choreo.DecisionTree.Analysis.decide(Choreo.DecisionTree.new(), %{})
{:error, "Tree has no root"}

This analysis answers the question: "Given feature values, what outcome does the tree predict?"

depth(tree)

Returns the maximum depth of the tree (number of edges from root to deepest leaf).

A single-node tree has depth 0.

Examples

iex> tree = Choreo.DecisionTree.new()
iex> tree = tree
...>   |> Choreo.DecisionTree.set_root(:a, feature: "a")
...>   |> Choreo.DecisionTree.add_decision(:b, feature: "b")
...>   |> Choreo.DecisionTree.add_outcome(:x)
...>   |> Choreo.DecisionTree.add_outcome(:y)
...>   |> Choreo.DecisionTree.branch(:a, :b, "1")
...>   |> Choreo.DecisionTree.branch(:b, :x, "2")
...>   |> Choreo.DecisionTree.branch(:b, :y, "3")
iex> Choreo.DecisionTree.Analysis.depth(tree)
2
iex> Choreo.DecisionTree.Analysis.depth(Choreo.DecisionTree.new())
0

This analysis answers the question: "How deep is the tree?"

feature_importance(decision_tree)

@spec feature_importance(Choreo.DecisionTree.t()) :: %{
  required(String.t()) => non_neg_integer()
}

Returns a map of feature frequencies across all decision nodes.

Useful for understanding which features drive the most splits.

Examples

iex> tree = Choreo.DecisionTree.new()
iex> tree = tree
...>   |> Choreo.DecisionTree.set_root(:weather, feature: "weather")
...>   |> Choreo.DecisionTree.add_decision(:wind, feature: "wind")
...>   |> Choreo.DecisionTree.add_outcome(:play)
...>   |> Choreo.DecisionTree.branch(:weather, :wind, "cloudy")
...>   |> Choreo.DecisionTree.branch(:wind, :play, "calm")
iex> Choreo.DecisionTree.Analysis.feature_importance(tree)
%{"weather" => 1, "wind" => 1}

This analysis answers the question: "Which features drive the most splits?"

generate_test_cases(tree)

@spec generate_test_cases(Choreo.DecisionTree.t()) :: [
  %{required(String.t()) => String.t()}
]

Generates feature maps that cover every reachable leaf path.

Each generated map can be passed to decide/2 to reach a distinct outcome. This is useful for testing or for validating that every rule in the tree is exercisable.

Examples

iex> tree = Choreo.DecisionTree.new()
iex> tree = tree
...>   |> Choreo.DecisionTree.set_root(:color, feature: "color")
...>   |> Choreo.DecisionTree.add_outcome(:stop, label: "Stop")
...>   |> Choreo.DecisionTree.add_outcome(:go, label: "Go")
...>   |> Choreo.DecisionTree.branch(:color, :stop, "red")
...>   |> Choreo.DecisionTree.branch(:color, :go, "green")
iex> test_cases = Choreo.DecisionTree.Analysis.generate_test_cases(tree)
iex> length(test_cases)
2
iex> %{"color" => "red"} in test_cases
true
iex> %{"color" => "green"} in test_cases
true

This analysis answers the question: "What inputs exercise every path?"

inconsistent_paths(tree)

@spec inconsistent_paths(Choreo.DecisionTree.t()) :: [{[Yog.node_id()], [String.t()]}]

Finds logically impossible paths where a feature is checked against mutually exclusive conditions.

Returns a list of tuples {path, [features_with_conflicts]}.

Examples

iex> tree = Choreo.DecisionTree.new()
iex> tree = tree
...>   |> Choreo.DecisionTree.set_root(:color, feature: "color")
...>   |> Choreo.DecisionTree.add_decision(:shade, feature: "color")
...>   |> Choreo.DecisionTree.add_outcome(:stop, label: "Stop")
...>   |> Choreo.DecisionTree.add_outcome(:go1, label: "Go")
...>   |> Choreo.DecisionTree.add_outcome(:go2, label: "Go")
...>   |> Choreo.DecisionTree.branch(:color, :shade, "red")
...>   |> Choreo.DecisionTree.branch(:color, :go1, "green")
...>   |> Choreo.DecisionTree.branch(:shade, :stop, "dark")
...>   |> Choreo.DecisionTree.branch(:shade, :go2, "light")
iex> inconsistencies = Choreo.DecisionTree.Analysis.inconsistent_paths(tree)
iex> length(inconsistencies)
2
iex> Enum.any?(inconsistencies, fn {_path, features} -> "color" in features end)
true

This analysis answers the question: "Are there logically impossible paths?"

missing_branches(tree, domains)

@spec missing_branches(Choreo.DecisionTree.t(), %{
  required(String.t()) => [String.t()]
}) :: [
  {Yog.node_id(), String.t(), [String.t()]}
]

Finds decision nodes whose outgoing branches do not cover an expected set of feature values.

Accepts a map of feature => [expected_values]. For each decision node testing that feature, returns {node_id, feature, missing_values} when values are absent.

Examples

iex> tree = Choreo.DecisionTree.new()
iex> tree = tree
...>   |> Choreo.DecisionTree.set_root(:color, feature: "color")
...>   |> Choreo.DecisionTree.add_outcome(:stop)
...>   |> Choreo.DecisionTree.add_outcome(:go)
...>   |> Choreo.DecisionTree.branch(:color, :stop, "red")
...>   |> Choreo.DecisionTree.branch(:color, :go, "green")
iex> Choreo.DecisionTree.Analysis.missing_branches(tree, %{"color" => ["red", "green", "blue"]})
[{:color, "color", ["blue"]}]

iex> tree = Choreo.DecisionTree.new()
iex> tree = tree
...>   |> Choreo.DecisionTree.set_root(:a, feature: "a")
...>   |> Choreo.DecisionTree.add_outcome(:x)
...>   |> Choreo.DecisionTree.branch(:a, :x, "1")
iex> Choreo.DecisionTree.Analysis.missing_branches(tree, %{"a" => ["1"]})
[]

This analysis answers the question: "Which expected branches are missing?"

orphan_nodes(tree)

@spec orphan_nodes(Choreo.DecisionTree.t()) :: [Yog.node_id()]

Returns nodes that are not reachable from the root.

In a well-formed tree every declared node should be reachable. Nodes added without a connecting branch are reported as orphans.

Examples

iex> tree = Choreo.DecisionTree.new()
iex> tree = tree
...>   |> Choreo.DecisionTree.set_root(:a, feature: "a")
...>   |> Choreo.DecisionTree.add_outcome(:x)
...>   |> Choreo.DecisionTree.add_outcome(:y)
...>   |> Choreo.DecisionTree.branch(:a, :x, "1")
iex> Choreo.DecisionTree.Analysis.orphan_nodes(tree)
[:y]

This analysis answers the question: "Which declared nodes are unreachable?"

paths(tree)

@spec paths(Choreo.DecisionTree.t()) :: [[Yog.node_id()]]

Enumerates all root-to-leaf paths.

Each path is a list of node IDs from root to outcome.

Examples

iex> tree = Choreo.DecisionTree.new()
iex> tree = tree
...>   |> Choreo.DecisionTree.set_root(:color, feature: "color")
...>   |> Choreo.DecisionTree.add_outcome(:stop, label: "Stop")
...>   |> Choreo.DecisionTree.add_outcome(:go, label: "Go")
...>   |> Choreo.DecisionTree.branch(:color, :stop, "red")
...>   |> Choreo.DecisionTree.branch(:color, :go, "green")
iex> Enum.sort(Choreo.DecisionTree.Analysis.paths(tree))
[[:color, :go], [:color, :stop]]

This analysis answers the question: "What are all possible root-to-leaf paths?"

paths_with_conditions(tree)

@spec paths_with_conditions(Choreo.DecisionTree.t()) :: [
  {[Yog.node_id()], [{Yog.node_id(), Yog.node_id(), String.t()}]}
]

Returns all root-to-leaf paths with their branch conditions.

Each result is {path, [{parent, child, condition}]}.

Examples

iex> tree = Choreo.DecisionTree.new()
iex> tree = tree
...>   |> Choreo.DecisionTree.set_root(:color, feature: "color")
...>   |> Choreo.DecisionTree.add_outcome(:stop, label: "Stop")
...>   |> Choreo.DecisionTree.add_outcome(:go, label: "Go")
...>   |> Choreo.DecisionTree.branch(:color, :stop, "red")
...>   |> Choreo.DecisionTree.branch(:color, :go, "green")
iex> paths = Choreo.DecisionTree.Analysis.paths_with_conditions(tree)
iex> {[:color, :stop], [{:color, :stop, "red"}]} in paths
true
iex> {[:color, :go], [{:color, :go, "green"}]} in paths
true

This analysis answers the question: "What are all paths with their branch conditions?"

prune_redundant(tree)

@spec prune_redundant(Choreo.DecisionTree.t()) :: Choreo.DecisionTree.t()

Prunes redundant decision nodes.

A decision is redundant when all of its descendant leaves share the same class label. The decision node is replaced by an outcome node with that label.

Returns a new tree.

Examples

iex> tree = Choreo.DecisionTree.new()
iex> tree = tree
...>   |> Choreo.DecisionTree.set_root(:color, feature: "color")
...>   |> Choreo.DecisionTree.add_decision(:shade, feature: "shade")
...>   |> Choreo.DecisionTree.add_outcome(:stop_light, label: "Stop", class: "stop")
...>   |> Choreo.DecisionTree.add_outcome(:stop_dark, label: "Stop", class: "stop")
...>   |> Choreo.DecisionTree.branch(:color, :shade, "red")
...>   |> Choreo.DecisionTree.branch(:shade, :stop_light, "light")
...>   |> Choreo.DecisionTree.branch(:shade, :stop_dark, "dark")
iex> pruned = Choreo.DecisionTree.Analysis.prune_redundant(tree)
iex> Choreo.DecisionTree.outcomes(pruned)
[:color]
iex> :color in Choreo.DecisionTree.decisions(pruned)
false

This analysis answers the question: "Which decision nodes can be simplified?"

reachable_outcomes(tree)

@spec reachable_outcomes(Choreo.DecisionTree.t()) :: [String.t()]

Returns the unique set of all possible outcome classes the tree can produce.

Only considers outcomes that are actually reachable from the root.

Examples

iex> tree = Choreo.DecisionTree.new()
iex> tree = tree
...>   |> Choreo.DecisionTree.set_root(:color, feature: "color")
...>   |> Choreo.DecisionTree.add_outcome(:stop, class: "stop")
...>   |> Choreo.DecisionTree.add_outcome(:go, class: "go")
...>   |> Choreo.DecisionTree.branch(:color, :stop, "red")
...>   |> Choreo.DecisionTree.branch(:color, :go, "green")
iex> Enum.sort(Choreo.DecisionTree.Analysis.reachable_outcomes(tree))
["go", "stop"]

This analysis answers the question: "What are all possible outcome classes?"

rules(tree)

@spec rules(Choreo.DecisionTree.t()) :: [
  %{
    conditions: %{required(String.t()) => String.t()},
    outcome: %{class: String.t() | nil, label: String.t() | nil}
  }
]

Extracts IF-THEN rules from the decision tree.

Each rule maps the conditions along a root-to-leaf path to the outcome reached at that leaf.

Examples

iex> tree = Choreo.DecisionTree.new()
iex> tree = tree
...>   |> Choreo.DecisionTree.set_root(:color, feature: "color")
...>   |> Choreo.DecisionTree.add_outcome(:stop, label: "Stop", class: "stop")
...>   |> Choreo.DecisionTree.add_outcome(:go, label: "Go", class: "go")
...>   |> Choreo.DecisionTree.branch(:color, :stop, "red")
...>   |> Choreo.DecisionTree.branch(:color, :go, "green")
iex> rules = Choreo.DecisionTree.Analysis.rules(tree)
iex> length(rules)
2
iex> Enum.find(rules, fn r -> r.outcome.class == "stop" end)
%{conditions: %{"color" => "red"}, outcome: %{class: "stop", label: "Stop"}}

This analysis answers the question: "What IF-THEN rules does the tree encode?"

validate(tree)

@spec validate(Choreo.DecisionTree.t()) :: [{:error | :warning, String.t()}]

Validates tree completeness.

Checks for:

  • missing root
  • decision nodes with no branches
  • outcome nodes with branches (should be leaves)
  • duplicate conditions from the same parent

Returns a list of {severity, message} tuples.

Examples

iex> tree = Choreo.DecisionTree.new()
iex> tree = tree
...>   |> Choreo.DecisionTree.set_root(:color, feature: "color")
...>   |> Choreo.DecisionTree.add_outcome(:stop)
...>   |> Choreo.DecisionTree.add_outcome(:go)
...>   |> Choreo.DecisionTree.branch(:color, :stop, "red")
...>   |> Choreo.DecisionTree.branch(:color, :go, "green")
iex> Choreo.DecisionTree.Analysis.validate(tree)
[]

iex> tree = Choreo.DecisionTree.new()
iex> Choreo.DecisionTree.Analysis.validate(tree)
[{:error, "Tree has no root"}]

This analysis answers the question: "Is the tree structurally valid?"