Choreo.DecisionTree.Analysis (Choreo v0.7.1)

Copy Markdown View Source

Analysis functions for Choreo.DecisionTree.

Provides path enumeration, evaluation, 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.

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

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.

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?"

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?"

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> :shade in Choreo.DecisionTree.outcomes(pruned)
true
iex> :shade 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?"

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?"