Эффективная реализация дерева в MATLAB

Класс дерева в MATLAB

Я внедряю структуру данных дерева в MATLAB. Добавление новых дочерних узлов в дерево, назначение и обновление значений данных, связанных с узлами, являются типичными операциями, которые я ожидаю выполнить. Каждый node имеет тот же тип data, который связан с ним. Удаление узлов не требуется для меня. До сих пор я решил реализовать класс, наследующий класс handle, чтобы иметь возможность передавать ссылки на узлы вокруг функций, которые будут изменять дерево.

Изменить: 2 декабря

Прежде всего, спасибо за все предложения в комментариях и ответах до сих пор. Они уже помогли мне улучшить мой древовидный класс.

Кто-то предложил попробовать digraph, представленный в R2015b. Мне еще предстоит изучить это, но, поскольку он не работает как ссылочный параметр аналогично классу, наследующему от handle, я немного скептически отношусь к тому, как он будет работать в моем приложении. На этом этапе еще не ясно, насколько легко будет работать с ним, используя пользовательский data для узлов и ребер.

Изменить: (3 декабря) Дополнительная информация о главном приложении: MCTS

Вначале я предположил, что детали основного приложения будут иметь только предельный интерес, но, читая комментарии и ответ от @FirefoxMetzger, я понимаю, что это имеет важные последствия.

Я реализую алгоритм алгоритм поиска по дереву Монте-Карло. Дерево поиска исследуется и расширяется итеративно. Википедия предлагает приятный графический обзор процесса: Поиск по дереву Монте-Карло

В моем приложении я выполняю большое количество поисковых итераций. На каждой итерации поиска я просматриваю текущее дерево, начиная с корня до листа node, затем разворачиваю дерево, добавляя новые узлы и повторяя. Поскольку метод основан на случайной выборке, в начале каждой итерации я не знаю, какой лист node я завершу на каждой итерации. Вместо этого это определяется совместно data узлов, находящихся в настоящее время в дереве, и результатов случайных выборок. Независимо от того, какие узлы я посещаю во время одной итерации, обновляется их data.

Пример. Я нахожусь в node n, у которого есть несколько детей. Мне нужно получить доступ к данным у каждого из детей и нарисовать случайную выборку, которая определяет, какой из детей я перехожу к следующему в поиске. Это повторяется до тех пор, пока не будет достигнут лист node. Практически я делаю это, вызывая функцию search в корне, которая решит, какой дочерний объект будет расширяться дальше, вызовите search на этом node рекурсивно и т.д., Наконец, вернув значение, как только лист node будет достиг. Это значение используется при возврате из рекурсивных функций для обновления data узлов, посещаемых во время итерации поиска.

Дерево может быть довольно неуравновешенным, так что некоторые ветки являются очень длинными цепочками узлов, а другие быстро заканчиваются после уровня корня и не расширяются дальше.

Текущая реализация

Ниже приведен пример моей текущей реализации с примером нескольких функций-членов для добавления узлов, запросов к глубине или количеству узлов в дереве и т.д.

classdef stree < handle
    %   A class for a tree object that acts like a reference
    %   parameter.
    %   The tree can be traversed in both directions by using the parent
    %   and children information.
    %   New nodes can be added to the tree. The object will automatically
    %   keep track of the number of nodes in the tree and increment the
    %   storage space as necessary.

    properties (SetAccess = private)
        % Hold the data at each node
        Node = { [] };
        % Index of the parent node. The root of the tree as a parent index
        % equal to 0.
        Parent = 0;
        num_nodes = 0;
        size_increment = 1;
        maxSize = 1;
    end

    methods
        function [obj, root_ID] = stree(data, init_siz)
            % New object with only root content, with specified initial
            % size
            obj.Node = repmat({ data },init_siz,1);
            obj.Parent = zeros(init_siz,1);
            root_ID = 1;
            obj.num_nodes = 1;
            obj.size_increment = init_siz;
            obj.maxSize = numel(obj.Parent);
        end

        function ID = addnode(obj, parent, data)
            % Add child node to specified parent
            if obj.num_nodes < obj.maxSize
                % still have room for data
                idx = obj.num_nodes + 1;
                obj.Node{idx} = data;
                obj.Parent(idx) = parent;
                obj.num_nodes = idx;
            else
                % all preallocated elements are in use, reserve more memory
                obj.Node = [
                    obj.Node
                    repmat({data},obj.size_increment,1)
                    ];

                obj.Parent = [
                    obj.Parent
                    parent
                    zeros(obj.size_increment-1,1)];
                obj.num_nodes = obj.num_nodes + 1;

                obj.maxSize = numel(obj.Parent);

            end
            ID = obj.num_nodes;
        end

        function content = get(obj, ID)
            %% GET  Return the contents of the given node IDs.
            content = [obj.Node{ID}];
        end

        function obj = set(obj, ID, content)
            %% SET  Set the content of given node ID and return the modifed tree.
            obj.Node{ID} = content;
        end

        function IDs = getchildren(obj, ID)
            % GETCHILDREN  Return the list of ID of the children of the given node ID.
            % The list is returned as a line vector.
            IDs = find( obj.Parent(1:obj.num_nodes) == ID );
            IDs = IDs';
        end
        function n = nnodes(obj)
            % NNODES  Return the number of nodes in the tree.
            % Equal to root + those whose parent is not root.
            n = 1 + sum(obj.Parent(1:obj.num_nodes) ~= 0);
            assert( obj.num_nodes == n);
        end

        function flag = isleaf(obj, ID)
            % ISLEAF  Return true if given ID matches a leaf node.
            % A leaf node is a node that has no children.
            flag = ~any( obj.Parent(1:obj.num_nodes) == ID );
        end

        function depth = depth(obj,ID)
            % DEPTH return depth of tree under ID. If ID is not given, use
            % root.
            if nargin == 1
                ID = 0;
            end
            if obj.isleaf(ID)
                depth = 0;
            else
                children = obj.getchildren(ID);
                NC = numel(children);
                d = 0; % Depth from here on out
                for k = 1:NC
                    d = max(d, obj.depth(children(k)));
                end
                depth = 1 + d;
            end
        end
    end
end

Однако производительность порой медленная, причем операции над деревом занимают большую часть моего времени вычисления. Какие конкретные способы могли бы сделать реализацию более эффективной? Было бы возможно изменить реализацию на что-то еще, чем тип наследования handle, если есть прирост производительности.

Результаты профилирования с текущей реализацией

Поскольку добавление новых узлов в дерево является наиболее типичной операцией (наряду с обновлением data node), я сделал несколько профилирование об этом. Я выполнил профайлер по следующему эталонному коду с Nd=6, Ns=10.

function T = benchmark(Nd, Ns)
% Tree benchmark. Nd: tree depth, Ns: number of nodes per layer
% Initialize tree
T = stree(rand, 10000);
add_layers(1, Nd);
    function add_layers(node_id, num_layers)
        if num_layers == 0
            return;
        end
        child_id = zeros(Ns,1);
        for s = 1:Ns
            % add child to current node
            child_id(s) = T.addnode(node_id, rand);

            % recursively increase depth under child_id(s)
            add_layers(child_id(s), num_layers-1);
        end
    end
end

Результаты профилирования: Результаты профилирования

Производительность R2015b


Было обнаружено, что R2015b улучшает производительность функций MATLAB OOP. Я перечеркнул вышеупомянутый бенчмарк и действительно наблюдал улучшение производительности:

Результат профилирования R2015b

Итак, это уже хорошая новость, хотя, конечно, принимаются и другие улучшения;)

Резервирование памяти по-разному

В комментариях также было предложено использовать

obj.Node = [obj.Node; data; cell(obj.size_increment - 1,1)];

зарезервировать больше памяти, чем текущий подход, с помощью repmat. Это немного улучшило производительность. Я должен отметить, что мой контрольный код предназначен для фиктивных данных, и поскольку фактический data более сложный, это, скорее всего, поможет. Благодарю! Результаты профилировщика ниже:

стиль резервной копии zeeMonkeez

Вопросы по еще большему повышению производительности

  • Возможно, существует альтернативный способ сохранить память для дерева, которое более эффективно? К сожалению, я обычно не знаю заранее, сколько узлов будет в дереве.
  • Добавление новых узлов и изменение data существующих узлов являются наиболее типичными операциями, которые я выполняю на дереве. На данный момент они фактически занимают большую часть времени обработки моего основного приложения. Любые улучшения в отношении этих функций будут приветствоваться.

Как последнее замечание, я бы идеально хотел, чтобы реализация была чистой MATLAB. Однако такие варианты, как MEX или использование некоторых встроенных функций Java, могут быть приемлемыми.

Ответы

Ответ 1

TL: DR. Вы полностью скопируете все данные, хранящиеся в каждой вставке, инициализируйте ячейку parent и Node больше, чем вы ожидаете.

У ваших данных есть древовидная структура, однако вы не используете это в своей реализации. Вместо этого реализованный код представляет собой вычислительную голодную версию таблицы поиска (фактически 2 таблицы), которая хранит данные и реляционные данные для дерева.

Причины, о которых я говорю, следующие:

  • Чтобы вставить вам вызов stree.addnote(parent, data), который будет хранить все данные в полях дерева stree Node = {} и Parent = []
  • вам, кажется, известно, какой элемент в вашем дереве вы хотите получить, поскольку код поиска не указан (если вы используете stree.getchild(ID) для него, у меня есть плохие новости)
  • После того, как вы обработали node, вы трассируете его с помощью find(), который является поиском по списку

Ни в коем случае это не означает, что реализация является неуклюжей для данных, она может быть даже самой лучшей, в зависимости от того, что вы делаете. Однако он объясняет проблемы с распределением памяти и дает подсказки о том, как их разрешать.


Хранить данные в виде таблицы поиска

Одним из способов хранения данных является сохранение базовой таблицы поиска. Я бы сделал это, только если вы знаете ID первого элемента, который вы хотите изменить , не ища его. Этот случай позволяет сделать вашу структуру более эффективной в два этапа.

Сначала инициализируйте массивы больше, а затем ожидайте, что вам нужно сохранить данные. Если превышена пропускная способность таблицы вверх, будет инициализирована новая, которая больше X-полей и будет сделана глубокая копия старых данных. Если вам нужно увеличить количество экземпляров один или два раза (во время всех вставок), это может не быть проблемой, но в вашем случае глубокая копия будет сделана навсегда!

Во-вторых, я бы изменил внутреннюю структуру и объединил две таблицы Node и parent. Причина этого в том, что обратное распространение в вашем коде принимает O (depth_from_root * n), где n - количество узлов в вашей таблице. Это потому, что find() будет перебирать всю таблицу для каждого родителя.

Вместо этого вы можете реализовать что-то похожее на

table = cell(n,1) % n bigger then expected value
end_pointer = 1 % simple pointer to the first free value

function insert(data,parent_ID)
    if end_pointer < numel(table)
        content.data = data;
        content.parent = parent_ID;
        table{end_pointer} = content;
        end_pointer = end_pointer + 1;
    else
        % need more space, make sure its enough this time
        table = [table cell(end_pointer,1)];
        insert(data,parent_ID);
    end
end

function content = get_value(ID)
    content = table(ID);
end

Это мгновенно дает вам доступ к родительскому ID без необходимости find(), сначала, сохраняя n итераций на каждом шаге, поэтому вы можете получить O (глубина). Если вы не знаете свой начальный node, тогда вы должны find() указать тот, который стоит O (n).

Обратите внимание, что эта структура не нуждается в is_leaf(), depth(), nnodes() или get_children(). Если вам все еще нужны те, мне нужно больше понять, что вы хотите делать с вашими данными, так как это очень влияет на правильную структуру.


Структура дерева

Эта структура имеет смысл, если вы никогда не знаете первый node ID и, следовательно, всегда должны искать для него.

Преимущество состоит в том, что поиск произвольной ноты работает с O (глубина), поэтому поиск O (глубина) вместо O (n), а обратное распространение - O (глубина ^ 2) вместо O (глубина + n). Обратите внимание, что глубина может быть чем угодно: от log (n) для идеально сбалансированного дерева, которое может быть возможно в зависимости от ваших данных, до n для вырожденного дерева, которое является связанным списком.

Однако, чтобы предложить что-то надлежащее, мне потребовалось бы более глубокое понимание, так как каждый вид древовидной структуры имеет свой собственный nich. Из того, что я вижу до сих пор, я предлагаю несимметричное дерево, которое "сортируется" по простому порядку, заданному узлом, которым нужен родитель. Это может быть дополнительно оптимизировано в зависимости от

  • Можно ли определить общий порядок ваших данных.
  • как вы относитесь к двойным значениям (одни и те же данные появляются дважды)
  • каков масштаб ваших данных (тысячи, миллионы,...)
  • - поиск/поиск всегда в паре с обратным распространением
  • как долго цепочки "родитель-ребенок" на ваших данных (или насколько сбалансированы и глубоки будут использовать этот простой порядок).
  • всегда есть только один родитель или один и тот же элемент, вставленный дважды с разными родителями.

Я с удовольствием предоставил бы пример кода для вышеупомянутого дерева, просто оставьте комментарий.

EDIT: В вашем случае оптимальным вариантом является неуравновешенное дерево (то есть параллельное исполнение для MCTS). В приведенном ниже коде предполагается, что данные разделены на state и score, а кроме того, a state является уникальным. Если это не так, это все равно будет работать, однако есть возможная оптимизация для повышения производительности MCTS.

classdef node < handle
    % A node for a tree in a MCTS
    properties
        state = {}; %some state of the search space that identifies the node
        score = 0;
        childs = cell(50,1);
        num_childs = 0;
    end
    methods
        function obj = node(state)
            % for a new node simulate a score using MC
            obj.score = simulate_from(state); % TODO implement simulation state -> finish
            obj.state = state;
        end
        function value = update(obj)
            % update the this node using MC recursively
            if obj.num_childs == numel(obj.childs)
                % there are to many childs, we have to expand the table
                obj.childs = [obj.childs cell(obj.num_childs,1)];
            end
            if obj.do_exploration() || obj.num_childs == 0
                % explore a potential state
                state_to_explore = obj.explore();

                %check if state has already been visited
                terminate = false;
                idx = 1;
                while idx <= obj.num_childs && ~terminate
                    if obj.childs{idx}.state_equals(state_to_explore)
                        terminate = true;
                    end
                    idx = idx + 1;
                end

                %preform the according action based on search
                if idx > obj.num_childs
                    % state has never been visited
                    % this action terminates the update recursion 
                    % and creates a new leaf
                    obj.num_childs = obj.num_childs + 1;
                    obj.childs{obj.num_childs} = node(state_to_explore);
                    value = obj.childs{obj.num_childs}.calculate_value();
                    obj.update_score(value);
                else
                    % state has been visited at least once
                    value = obj.childs{idx}.update();
                    obj.update_score(value);
                end
            else
                % exploit what we know already
                best_idx = 1;
                for idx = 1:obj.num_childs
                    if obj.childs{idx}.score > obj.childs{best_idx}.score
                        best_idx = idx;
                    end
                end
                value = obj.childs{best_idx}.update();
                obj.update_score(value);
            end
            value = obj.calculate_value();
        end
        function state = explore(obj)
            %select a next state to explore, that may or may not be visited
            %TODO
        end
        function bool = do_exploration(obj)
            % decide if this node should be explored or exploited
            %TODO
        end
        function bool = state_equals(obj, test_state)
            % returns true if the nodes state is equal to test_state
            %TODO
        end
        function update_score(obj, value)
            % updates the score based on some value
            %TODO
        end
        function calculate_value(obj)
            % returns the value of this node to update previous nodes
            %TODO
        end
    end
end

Несколько комментариев к коду:

  • в зависимости от настройки obj.calculate_value() может и не понадобиться. Например. если это какое-то значение, которое может быть вычислено путем оценки только дочерних показателей
  • Если a state может иметь несколько родителей, имеет смысл повторно использовать объект примечания и покрыть его в структуре
  • поскольку каждый Node знает все его дочерние элементы, поддерево может быть легко сгенерировано с помощью Node как root node
  • поиск дерева (без какого-либо обновления) - это простой рекурсивный жадный поиск
  • в зависимости от фактора разветвления вашего поиска, возможно, стоит посетить каждый возможный дочерний один раз (после инициализации node), а затем выполнить randsample(obj.childs,1) для исследования, поскольку это позволяет избежать копирования/перераспределения дочернего массива
  • свойство parent кодируется, когда дерево обновляется рекурсивно, передавая value родительскому элементу после завершения обновления для node
  • Единственное время, когда я перераспределяю память, - это когда один node имеет более 50 дочерних элементов, я только перераспределяю для этого отдельного node

Это должно работать намного быстрее, поскольку оно просто беспокоится о том, какая часть дерева выбрана и не трогает ничего.

Ответ 2

Я знаю, что это может показаться глупым... но как насчет сохранения количества свободных узлов вместо общего количества узлов? Это потребует сравнения с константой (которая равна нулю), которая представляет собой доступ к одному свойству.

Еще одно улучшение voodoo будет перемещаться .maxSize рядом с .num_nodes и помещать их перед ячейкой .Node. Таким образом, их положение в памяти не изменится относительно начала объекта из-за роста свойства .Node (вуду здесь, когда я угадываю внутреннюю реализацию объектов в MATLAB).

Позднее редактирование. Когда я профилировал с .Node, перемещенным в конце списка свойств, основная часть времени выполнения была потреблена путем расширения свойства .Node, как и ожидалось (5.45 секунды, по сравнению с 1,25 с для сравнения, о котором вы упомянули).

Ответ 3

Вы можете попытаться выделить несколько элементов, которые пропорциональны количеству фактически заполненных элементов: это стандартная реализация для std::vector в С++

obj.Node = [obj.Node; data; cell(q * obj.num_nodes,1)];

Я точно не помню, но в MSCC q равен 1, а для GCC - 0,75.


Это решение с использованием Java. Мне это не очень нравится, но он выполняет свою работу. Я реализовал пример, извлеченный из википедии.

import javax.swing.tree.DefaultMutableTreeNode

% Let create our example tree
top = DefaultMutableTreeNode([11,21])
n1 = DefaultMutableTreeNode([7,10])
top.add(n1)
n2 = DefaultMutableTreeNode([2,4])
n1.add(n2)
n2 = DefaultMutableTreeNode([5,6])
n1.add(n2)
n3 = DefaultMutableTreeNode([2,3])
n2.add(n3)
n3 = DefaultMutableTreeNode([3,3])
n2.add(n3)
n1 = DefaultMutableTreeNode([4,8])
top.add(n1)
n2 = DefaultMutableTreeNode([1,2])
n1.add(n2)
n2 = DefaultMutableTreeNode([2,3])
n1.add(n2)
n2 = DefaultMutableTreeNode([2,3])
n1.add(n2)
n1 = DefaultMutableTreeNode([0,3])
top.add(n1)

% Element to look for, your implementation will be recursive
searching = [0 1 1];
idx = 1;
node(idx) = top;
for item = searching,
    % Java transposes the matrices, remember to transpose back when you are reading
    node(idx).getUserObject()'
    node(idx+1) = node(idx).getChildAt(item);
    idx = idx + 1;
end
node(idx).getUserObject()'

% We made a new test...
newdata = [0, 1]
newnode = DefaultMutableTreeNode(newdata)
% ...so we expand our tree at the last node we searched
node(idx).add(newnode)

% The change has to be propagated (this is where your recursion returns)
for it=length(node):-1:1,
    itnode=node(it);
    val = itnode.getUserObject()'
    newitemdata = val + newdata
    itnode.setUserObject(newitemdata)
end

% Let see if the new values are correct
searching = [0 1 1 0];
idx = 1;
node(idx) = top;
for item = searching,
    node(idx).getUserObject()'
    node(idx+1) = node(idx).getChildAt(item);
    idx = idx + 1;
end
node(idx).getUserObject()'