diff --git a/crossselling.php b/crossselling.php index 32781ab..d4b1d99 100755 --- a/crossselling.php +++ b/crossselling.php @@ -28,6 +28,8 @@ exit; } +require_once dirname(__FILE__)."/fpgrowth/PrestashopWrapper.php"; + class CrossSelling extends Module { protected $html; @@ -56,8 +58,8 @@ public function install() !$this->registerHook('shoppingCart') || !$this->registerHook('actionOrderStatusPostUpdate') || !Configuration::updateValue('CROSSSELLING_DISPLAY_PRICE', 0) || - !Configuration::updateValue('CROSSSELLING_NBR', 10) - ) { + !Configuration::updateValue('CROSSSELLING_NBR', 10) || + !\fpgrowth\PrestashopWrapper::createTable() ) { return false; } $this->_clearCache('crossselling.tpl'); @@ -65,6 +67,12 @@ public function install() return true; } + public function enable() + { + return parent::enable(); + + } + public function uninstall() { $this->_clearCache('crossselling.tpl'); @@ -95,6 +103,9 @@ public function getContent() $this->_clearCache('crossselling.tpl'); $this->html .= $this->displayConfirmation($this->l('Settings updated successfully')); } + }elseif (Tools::isSubmit('submitRefresh')) { + $this->processTransactionsDb(); + $this->html .= $this->displayConfirmation($this->l('Rules updated successfully')); } return $this->html.$this->renderForm(); @@ -120,66 +131,57 @@ public function hookHeader() $this->context->controller->addJqueryPlugin(array('scrollTo', 'serialScroll', 'bxslider')); } + + + /** * @param array $products_id an array of product ids * @return array */ protected function getOrderProducts(array $products_id) { - $q_orders = 'SELECT o.id_order - FROM '._DB_PREFIX_.'orders o - LEFT JOIN '._DB_PREFIX_.'order_detail od ON (od.id_order = o.id_order) - WHERE o.valid = 1 AND od.product_id IN ('.implode(',', $products_id).')'; - $orders = Db::getInstance(_PS_USE_SQL_SLAVE_)->executeS($q_orders); - $final_products_list = array(); + $list_product_ids = join(',', $products_id); + + if (Group::isFeatureActive()) { + $sql_groups_join = ' + LEFT JOIN `'._DB_PREFIX_.'category_product` cp ON (cp.`id_category` = product_shop.id_category_default + AND cp.id_product = product_shop.id_product) + LEFT JOIN `'._DB_PREFIX_.'category_group` cg ON (cp.`id_category` = cg.`id_category`)'; + $groups = FrontController::getCurrentCustomerGroups(); + $sql_groups_where = 'AND cg.`id_group` '.(count($groups) ? 'IN ('.implode(',', $groups).')' : '='.(int)Group::getCurrent()->id); + } - if (count($orders) > 0) { - $list = ''; - foreach ($orders as $order) { - $list .= (int)$order['id_order'].','; - } - $list = rtrim($list, ','); - - $list_product_ids = join(',', $products_id); - - if (Group::isFeatureActive()) { - $sql_groups_join = ' - LEFT JOIN `'._DB_PREFIX_.'category_product` cp ON (cp.`id_category` = product_shop.id_category_default - AND cp.id_product = product_shop.id_product) - LEFT JOIN `'._DB_PREFIX_.'category_group` cg ON (cp.`id_category` = cg.`id_category`)'; - $groups = FrontController::getCurrentCustomerGroups(); - $sql_groups_where = 'AND cg.`id_group` '.(count($groups) ? 'IN ('.implode(',', $groups).')' : '='.(int)Group::getCurrent()->id); - } - - $order_products = Db::getInstance(_PS_USE_SQL_SLAVE_)->executeS(' - SELECT DISTINCT od.product_id, pl.name, pl.description_short, pl.link_rewrite, p.reference, i.id_image, product_shop.show_price, - cl.link_rewrite category, p.ean13, stock.out_of_stock, IFNULL(stock.quantity, 0) as quantity - FROM '._DB_PREFIX_.'order_detail od - LEFT JOIN '._DB_PREFIX_.'product p ON (p.id_product = od.product_id) - '.Shop::addSqlAssociation('product', 'p'). - (Combination::isFeatureActive() ? 'LEFT JOIN `'._DB_PREFIX_.'product_attribute` pa - ON (p.`id_product` = pa.`id_product`) - '.Shop::addSqlAssociation('product_attribute', 'pa', false, 'product_attribute_shop.`default_on` = 1').' - '.Product::sqlStock('p', 'product_attribute_shop', false, $this->context->shop) : Product::sqlStock('p', 'product', false, - $this->context->shop)).' - LEFT JOIN '._DB_PREFIX_.'product_lang pl ON (pl.id_product = od.product_id'.Shop::addSqlRestrictionOnLang('pl').') - LEFT JOIN '._DB_PREFIX_.'category_lang cl ON (cl.id_category = product_shop.id_category_default' - .Shop::addSqlRestrictionOnLang('cl').') - LEFT JOIN '._DB_PREFIX_.'image i ON (i.id_product = od.product_id) - '.(Group::isFeatureActive() ? $sql_groups_join : '').' - WHERE od.id_order IN ('.$list.') + $sqlSelection = ' + SELECT DISTINCT cpa.id_product_related as product_id, pl.name, pl.description_short, pl.link_rewrite, p.reference, i.id_image, product_shop.show_price, + cl.link_rewrite category, p.ean13, stock.out_of_stock, IFNULL(stock.quantity, 0) as quantity + FROM '._DB_PREFIX_.'crossselling_pair cpa + LEFT JOIN '._DB_PREFIX_.'product p ON (p.id_product = cpa.id_product_related) + '.Shop::addSqlAssociation('product', 'p'). + (Combination::isFeatureActive() ? 'LEFT JOIN `'._DB_PREFIX_.'product_attribute` pa + ON (p.`id_product` = pa.`id_product`) + '.Shop::addSqlAssociation('product_attribute', 'pa', false, 'product_attribute_shop.`default_on` = 1').' + '.Product::sqlStock('p', 'product_attribute_shop', false, $this->context->shop) : Product::sqlStock('p', 'product', false, + $this->context->shop)).' + LEFT JOIN '._DB_PREFIX_.'product_lang pl ON (pl.id_product = cpa.id_product_related'.Shop::addSqlRestrictionOnLang('pl').') + LEFT JOIN '._DB_PREFIX_.'category_lang cl ON (cl.id_category = product_shop.id_category_default' + .Shop::addSqlRestrictionOnLang('cl').') + LEFT JOIN '._DB_PREFIX_.'image i ON (i.id_product = cpa.id_product_related) + '.(Group::isFeatureActive() ? $sql_groups_join : '').' + WHERE cpa.id_product_main in ('.$list_product_ids.') + AND cpa.id_product_related NOT IN ('.$list_product_ids.') AND pl.id_lang = '.(int)$this->context->language->id.' AND cl.id_lang = '.(int)$this->context->language->id.' - AND od.product_id NOT IN ('.$list_product_ids.') AND i.cover = 1 AND product_shop.active = 1 '.(Group::isFeatureActive() ? $sql_groups_where : '').' - ORDER BY RAND() - LIMIT '.(int)Configuration::get('CROSSSELLING_NBR')); + ORDER BY cpa.support desc + LIMIT '.(int)Configuration::get('CROSSSELLING_NBR'); - $tax_calc = Product::getTaxCalculationMethod(); + $order_products = Db::getInstance(_PS_USE_SQL_SLAVE_)->executeS($sqlSelection); + if($order_products != false){ + $tax_calc = Product::getTaxCalculationMethod(); foreach ($order_products as &$order_product) { $order_product['id_product'] = (int)$order_product['product_id']; $order_product['image'] = $this->context->link->getImageLink($order_product['link_rewrite'], @@ -198,7 +200,6 @@ protected function getOrderProducts(array $products_id) } } } - return $final_products_list; } @@ -250,10 +251,10 @@ public function displayProductListReviews($params) */ public function hookProductFooter($params) { - $cache_id = 'crossselling|productfooter|'.(int)$params['product']->id; + $cache_id = 'crossselling|productfooter|'.(int)$params['product']['id']; + if (!$this->isCached('crossselling.tpl', $this->getCacheId($cache_id))) { + $final_products_list = $this->getOrderProducts(array($params['product']['id'])); - if (!$this->isCached('crossselling.tpl', $this->getCacheId($cache_id))) { - $final_products_list = $this->getOrderProducts(array($params['product']->id)); if (count($final_products_list) > 0) { $this->smarty->assign( @@ -313,8 +314,9 @@ public function renderForm() 'title' => $this->l('Save'), ) ), - ); + ); + $helper = new HelperForm(); $helper->show_toolbar = false; $helper->table = $this->table; @@ -332,7 +334,23 @@ public function renderForm() 'id_language' => $this->context->language->id ); - return $helper->generateForm(array($fields_form)); + $forms = $helper->generateForm(array($fields_form)); + + + $fields_form = array( + 'form' => array( + 'legend' => array( + 'title' => $this->l('Association rules') + ), + 'submit' => array( + 'title' => $this->l('Process Transactions'), + ) + ), + + ); + $helper->submit_action = 'submitRefresh'; + $forms .= $helper->generateForm(array($fields_form)); + return $forms; } public function getConfigFieldsValues() @@ -342,4 +360,14 @@ public function getConfigFieldsValues() 'CROSSSELLING_DISPLAY_PRICE' => Tools::getValue('CROSSSELLING_DISPLAY_PRICE', Configuration::get('CROSSSELLING_DISPLAY_PRICE')), ); } + + /** + * Processes all the carts in search of product association rules + * Saves them into the database + */ + public function processTransactionsDb(){ + $wrapper = new fpgrowth\PrestashopWrapper(); + $levels = $wrapper->getProductAssociationRules(); + $wrapper->saveProductAssociationRules($levels); + } } diff --git a/fpgrowth/AlgoFPGrowth.php b/fpgrowth/AlgoFPGrowth.php new file mode 100644 index 0000000..99546a4 --- /dev/null +++ b/fpgrowth/AlgoFPGrowth.php @@ -0,0 +1,397 @@ + + * @copyright 2007-2016 PrestaShop SA + * @license http://opensource.org/licenses/afl-3.0.php Academic Free License (AFL 3.0) + * International Registered Trademark & Property of PrestaShop SA + */ + +namespace fpgrowth; + +require_once "TransactionFileGenerator.php"; +require_once "Itemset.php"; +require_once "Itemsets.php"; +require_once "FPTree.php"; +require_once "FPNode.php"; + + +/** + * This is an implementation of the FPGROWTH algorithm (Han et al., 2004), + * derived from the work of Philippe Fournier-Viger http://www.philippe-fournier-viger.com/spmf + * FPGrowth is described here: + * + * Han, J., Pei, J., & Yin, Y. (2000, May). Mining frequent patterns without candidate generation. In ACM SIGMOD Record (Vol. 29, No. 2, pp. 1-12). ACM + */ +class AlgoFPGrowth +{ + // This variable is used to determine the size of buffers to store itemsets. + // A value of 50 is enough because it allows up to 2^50 patterns! + const BUFFERS_SIZE = 50; + + // for statistics + public $startTimestamp;// start time of the latest execution + public $endTime; // end time of the latest execution + public $transactionCount = 0; // transaction count in the database + public $itemsetCount; // number of freq. itemsets found + public $memmoryUsed = 0; + + // parameter + public $minSupportRelative;// the relative minimum support + + // The patterns that are found + // (if the user want to keep them into memory) + public $patterns = null; + // buffer for storing the current itemset that is mined when performing mining + // the idea is to always reuse the same buffer to reduce memory usage. + public $itemsetBuffer = null; + // another buffer for storing fpnodes in a single path of the tree + public $fpNodeTempBuffer = null; + // This buffer is used to store an itemset that will be written to file + // so that the algorithm can sort the itemset before it is output to file + // (when the user choose to output result to file). + public $itemsetOutputBuffer = null; + + /** + * Method to run the FPGRowth algorithm. + * @param mixed $input an array like db of transactions. + * @param float $minsupp the minimum support threshold. + * @param mixed $mapSupport + * @param int $transactionCount number of transactions passed + * @return fpgrowth /Itemsets result if no output file path is provided. + */ + public function runAlgorithm($input, $minsupp, $mapSupport = null, $transactionCount = null) + { + + // record start time + $this->startTimestamp = microtime(true); + // number of itemsets found + $this->itemsetCount = 0; + + //initialize tool to record memory usage + $this->memmoryUsed = memory_get_usage(); + $this->patterns = new Itemsets("FREQUENT ITEMSETS"); + + // (1) PREPROCESSING: Initial database scan to determine the frequency of each item + // The frequency is stored in a map: + // key: item value: support + if ($mapSupport == null) { + $mapSupport = $this->scanDatabaseToDetermineFrequencyOfSingleItems($input); + } + if($transactionCount != null){ + $this->transactionCount = $transactionCount; + } + + // convert the minimum support as percentage to a + // relative minimum support + $this->minSupportRelative = ceil($minsupp * $this->transactionCount); + + // (2) Scan the database again to build the initial FP-Tree + // Before inserting a transaction in the FPTree, we sort the items + // by descending order of support. We ignore items that + // do not have the minimum support. + $tree = new FPTree(); + + // for each line (transaction) until the end of the file + foreach ($input as $transactionLine) { + $transaction = []; + // for each item in the transaction add items that have the minimum support + foreach ($transactionLine as $item) { + if ($mapSupport["$item"] >= $this->minSupportRelative) { + $transaction[] = $item; + } + } + $this->sortTransaction($transaction, $mapSupport); + + // add the sorted transaction to the fptree. + $tree->addTransaction($transaction); + } + + // We create the header table for the tree using the calculated support of single items + $tree->createHeaderList($mapSupport); + + + // (5) We start to mine the FP-Tree by calling the recursive method. + // Initially, the prefix alpha is empty. + // if at least an item is frequent + if (count($tree->headerList) > 0) { + // initialize the buffer for storing the current itemset + $itemsetBuffer = new \SplFixedArray($this::BUFFERS_SIZE); + // and another buffer + $this->fpNodeTempBuffer = new \SplFixedArray($this::BUFFERS_SIZE); + // recursively generate frequent itemsets using the fp-tree + // Note: we assume that the initial FP-Tree has more than one path + // which should generally be the case. + $this->fpgrowth($tree, $itemsetBuffer, 0, $this->transactionCount, $mapSupport); + } + + // record the execution end time + $this->endTime = microtime(true); + // check the memory usage + $this->memmoryUsed = (memory_get_usage() - $this->memmoryUsed) / (1024); + // return the result (if saved to memory) + return $this->patterns; + } + + /** + * Sort the transaction by support or lexical ordering + * @param $transaction + * @param $mapSupport + */ + public function sortTransaction(&$transaction, $mapSupport) + { + $sortFunction = function ($id1, $id2) use (&$mapSupport) { + // compare the support + $compare = $mapSupport["$id2"] - $mapSupport["$id1"]; + // if the same frequency, we check the lexical ordering! + // otherwise we use the support + return ($compare == 0) ? ($id1 - $id2) : $compare; + }; + usort($transaction, $sortFunction); + } + + /** + * Mine an FP-Tree having more than one path. + * @param $tree array the FP-tree + * @param $prefix int the current prefix, named "alpha" + * @param $prefixLength array the frequency of items in the FP-Tree + * @param $prefixSupport int + * @param $mapSupport mixed + */ + private function fpgrowth($tree, $prefix, $prefixLength, $prefixSupport, $mapSupport) + { + // We will check if the FPtree contains a single path + $singlePath = true; + // We will use a variable to keep the support of the single path if there is one + $singlePathSupport = 0; + // This variable is used to count the number of items in the single path + // if there is one + $position = 0; + // if the root has more than one child, than it is not a single path + if (count($tree->root->childs) > 1) { + $singlePath = false; + } else { + // Explore the single path + // if the root has exactly one child, we need to recursively check childs + // of the child to see if they also have one child + $currentNode = $tree->root->childs[0]; + while (true) { + // if the current child has more than one child, it is not a single path! + if (count($currentNode->childs) > 1) { + $singlePath = false; + break; + } + // otherwise, we copy the current item in the buffer and move to the child + // the buffer will be used to store all items in the path + $this->fpNodeTempBuffer[$position] = $currentNode; + $position++; + $singlePathSupport = $currentNode->counter; + // if this node has no child, that means that this is the end of this path + // and it is a single path, so we break + if (count($currentNode->childs) == 0) { + break; + } + $currentNode = $currentNode->childs[0]; + } + } + + // Case 1: the FPtree contains a single path + if ($singlePath && $singlePathSupport >= $this->minSupportRelative) { + // We save the path, because it is a maximal itemset + $this->saveAllCombinationsOfPrefixPath($this->fpNodeTempBuffer, $position, $prefix, $prefixLength); + } else { + // For each frequent item in the header table list of the tree in reverse order. + for ($i = count($tree->headerList) - 1; $i >= 0; $i--) { + // get the item + $item = $tree->headerList[$i]; + // get the item support + $support = $mapSupport["$item"]; + // Create Beta by concatening prefix Alpha by adding the current item to alpha + $prefix[$prefixLength] = $item; + // calculate the support of the new prefix beta + $betaSupport = ($prefixSupport < $support) ? $prefixSupport : $support; + // save beta + $this->saveItemset($prefix, $prefixLength + 1, $betaSupport); + // === (A) Construct beta's conditional pattern base === + // It is a subdatabase which consists of the set of prefix paths + // in the FP-tree co-occuring with the prefix pattern. + $prefixPaths = []; + $path = $tree->mapItemNodes[$item]; + // Map to count the support of items in the conditional prefix tree + // Key: item Value: support + $mapSupportBeta = []; + while ($path != null) { + // if the path is not just the root node + if ($path->parent->itemID != -1) { + // create the prefixpath + $prefixPath = []; + // add this node. + $prefixPath[] = $path; // NOTE: we add it just to keep its support, + // actually it should not be part of the prefixPath + $pathCount = $path->counter; + + //Recursively add all the parents of this node. + $parent = $path->parent; + while ($parent->itemID != -1) { + $prefixPath[] = $parent; + // FOR EACH PATTERN WE ALSO UPDATE THE ITEM SUPPORT AT THE SAME TIME + // if the first time we see that node id + if (!isset($mapSupportBeta["{$parent->itemID}"])) { + // just add the path count + $mapSupportBeta["{$parent->itemID}"] = $pathCount; + } else { + // otherwise, make the sum with the value already stored + $mapSupportBeta["{$parent->itemID}"] = $mapSupportBeta["{$parent->itemID}"] + $pathCount; + } + $parent = $parent->parent; + } + // add the path to the list of prefixpaths + $prefixPaths[] = $prefixPath; + } + // We will look for the next prefixpath + $path = $path->nodeLink; + } + + // (B) Construct beta's conditional FP-Tree + // Create the tree. + $treeBeta = new FPTree(); + // Add each prefixpath in the FP-tree. + foreach ($prefixPaths as $prefixPath) { + $treeBeta->addPrefixPath($prefixPath, $mapSupportBeta, $this->minSupportRelative); + } + + // Mine recursively the Beta tree if the root has child(s) + if (count($treeBeta->root->childs) > 0) { + // Create the header list. + $treeBeta->createHeaderList($mapSupportBeta); + // recursive call + $this->fpgrowth($treeBeta, $prefix, $prefixLength + 1, $betaSupport, $mapSupportBeta); + } + } + } + + } + + /** + * This method saves all combinations of a prefix path if it has enough support + * @param $fpNodeTempBuffer mixed + * @param $position int + * @param $prefix mixed the current prefix + * @param $prefixLength int the current prefix length + * TODO: Check this out with a test + */ + public function saveAllCombinationsOfPrefixPath($fpNodeTempBuffer, $position, + $prefix, $prefixLength) + { + $support = 0; + // Generate all subsets of the prefixPath except the empty set + // and output them + // We use bits to generate all subsets. + $i = 1; + $max = pow(2, $position); + for (; $i < $max; $i++) { + // we create a new subset + $newPrefixLength = $prefixLength; + // for each bit + for ($j = 0; $j < $position; $j++) { + // check if the j bit is set to 1 + $isSet = (int)$i & pow(2, $j); + // if yes, add the bit position as an item to the new subset + if ($isSet > 0) { + $prefix[$newPrefixLength++] = $fpNodeTempBuffer[$j]->itemID; + if ($support == 0) { + $support = $fpNodeTempBuffer[$j]->counter; + } + } + } + // save the itemset + $this->saveItemset($prefix, $newPrefixLength, $support); + } + } + + + /** + * This method scans the input database to calculate the support of single items + * @param $input string the path of the input file + * @return mixed a map for storing the support of each item (key: item, value: support) + * TODO: Turn it into SQL query + */ + public function scanDatabaseToDetermineFrequencyOfSingleItems($input) + { + + // a map for storing the support of each item (key: item, value: support) + $mapSupport = []; + foreach ($input as $lineSplited) { + // for each item + foreach ($lineSplited as $item) { + // increase the support count of the item + if (!isset($mapSupport["$item"])) { + $mapSupport["$item"] = 1; + } else { + $mapSupport["$item"] += 1; + } + } + // increase the transaction count + $this->transactionCount++; + } + // Unset the file to call __destruct(), closing the file handle. + $file = null; + return $mapSupport; + } + + + /** + * Write a frequent itemset that is found to the output file or + * keep into memory if the user prefer that the result be saved into memory. + * @param $itemset \SplFixedArray + * @param $itemsetLength int + * @param $support int + */ + private function saveItemset($itemset, $itemsetLength, $support) + { + // increase the number of itemsets found for statistics purpose + $this->itemsetCount++; + // create an object Itemset and add it to the set of patterns + // found. + $itemsetArray = array_slice($itemset->toArray(), 0, $itemsetLength); + // sort the itemset so that it is sorted according to lexical ordering before we show it to the user + sort($itemsetArray); + $itemsetObj = new Itemset($itemsetArray); + $itemsetObj->setAbsoluteSupport($support); + $this->patterns->addItemset($itemsetObj, $itemsetLength); + } + + /** + * Print statistics about the algorithm execution to System.out. + */ + public function printStats() + { + print("============= FP-GROWTH 0.96r19 - STATS ============="); + $temps = $this->endTime - $this->startTimestamp; + print("\n Transactions count from database : {$this->transactionCount}"); + print("\n Min support relative: $this->minSupportRelative"); + print("\n Max memory usage Kb: {$this->memmoryUsed}"); + print("\n Frequent itemsets count : {$this->itemsetCount}"); + print("\n Total time ~ {$temps} ms"); + print("\n==================================================="); + } + + +} diff --git a/fpgrowth/FPNode.php b/fpgrowth/FPNode.php new file mode 100644 index 0000000..0e8d5fa --- /dev/null +++ b/fpgrowth/FPNode.php @@ -0,0 +1,75 @@ + + * @copyright 2007-2016 PrestaShop SA + * @license http://opensource.org/licenses/afl-3.0.php Academic Free License (AFL 3.0) + * International Registered Trademark & Property of PrestaShop SA + */ + +namespace fpgrowth; + +/** + * This is an implementation of a FPTree node as used by the FPGrowth algorithm. + * + */ +class FPNode { + public $itemID = -1; // item id + public $counter = 1; // frequency counter (a.k.a. support) + + // the parent node of that node or null if it is the root + public $parent = null; + // the child nodes of that node + public $childs = []; + public $nodeLink = null; // link to next node with the same item id (for the header table). + + /** + * Return the immediate child of this node having a given ID. + * If there is no such child, return null; + * @param int $id + * @return mixed|null + */ + function getChildWithID($id) { + // for each child node + foreach($this->childs as $child){ + // if the id is the one that we are looking for + if($child->itemID == $id){ + // return that node + return $child; + } + } + // if not found, return null + return null; + } + + /** + * Method for getting a string representation of this tree + * (to be used for debugging purposes). + * @return string a string + */ + public function __toString() { + $strOut = "( id={$this->itemID} count={$this->counter})\n"; + foreach($this->childs as $child) { + $strOut .= " " . $child; + } + return $strOut; + } + +} diff --git a/fpgrowth/FPTree.php b/fpgrowth/FPTree.php new file mode 100644 index 0000000..074ed87 --- /dev/null +++ b/fpgrowth/FPTree.php @@ -0,0 +1,183 @@ + + * @copyright 2007-2016 PrestaShop SA + * @license http://opensource.org/licenses/afl-3.0.php Academic Free License (AFL 3.0) + * International Registered Trademark & Property of PrestaShop SA + */ + +namespace fpgrowth; + + +/** + * This is an implementation of a FPTree as used by the FPGrowth algorithm. + * + */ +class FPTree +{ + // List of items in the header table + public $headerList = []; + + // List of pairs (item, frequency) of the header table + public $mapItemNodes = []; + + // Map that indicates the last node for each item using the node links + // key: item value: an fp tree node + public $mapItemLastNode = []; + + // root of the tree + public $root; // null node + + public function __construct() + { + $this->root = new FPNode(); + } + + /** + * Method for adding a transaction to the fp-tree (for the initial construction + * of the FP-Tree). + * @param transaction + */ + public function addTransaction($transaction) + { + $currentNode = $this->root; + // For each item in the transaction + foreach ($transaction as $item) { + // look if there is a node already in the FP-Tree + $child = $currentNode->getChildWithID($item); + if ($child == null) { + // there is no node, we create a new one + $newNode = new FPNode(); + $newNode->itemID = $item; + $newNode->parent = $currentNode; + // we link the new node to its parrent + $currentNode->childs[] = $newNode; + // we take this node as the current node for the next for loop iteration + $currentNode = $newNode; + // We update the header table. + // We check if there is already a node with this id in the header table + $this->fixNodeLinks($item, $newNode); + } else { + // there is a node already, we update it + $child->counter++; + $currentNode = $child; + } + } + } + + /** + * Method to fix the node link for an item after inserting a new node. + * @param int $item the item of the new node + * @param fpgrowth /FPNode $newNode the new node thas has been inserted. + */ + private function fixNodeLinks($item, $newNode) + { + // get the latest node in the tree with this item + if (isset($this->mapItemLastNode[$item])) { + // if not null, then we add the new node to the node link of the last node + $lastNode = $this->mapItemLastNode[$item]; + $lastNode->nodeLink = $newNode; + } + // Finally, we set the new node as the last node + $this->mapItemLastNode[$item] = $newNode; + + if (!isset($this->mapItemNodes[$item])) { // there is not + $this->mapItemNodes[$item] = $newNode; + } + } + + /** + * Method for adding a prefixpath to a fp-tree. + * @param $prefixPath The prefix path + * @param $mapSupportBeta The frequencies of items in the prefixpaths + * @param $relativeMinsupp + */ + function addPrefixPath($prefixPath, $mapSupportBeta, $relativeMinsupp) + { + // the first element of the prefix path contains the path support + $pathCount = $prefixPath[0]->counter; + + $currentNode = $this->root; + // For each item in the transaction (in backward order) + // (and we ignore the first element of the prefix path) + for ($i = count($prefixPath) - 1; $i >= 1; $i--) { + $pathItem = $prefixPath[$i]; + // if the item is not frequent we skip it + if ($mapSupportBeta[$pathItem->itemID] >= $relativeMinsupp) { + + // look if there is a node already in the FP-Tree + $child = $currentNode->getChildWithID($pathItem->itemID); + if ($child == null) { + // there is no node, we create a new one + $newNode = new FPNode(); + $newNode->itemID = $pathItem->itemID; + $newNode->parent = $currentNode; + $newNode->counter = $pathCount; // set its support + $currentNode->childs[] = $newNode; + $currentNode = $newNode; + // We update the header table. + // and the node links + $this->fixNodeLinks($pathItem->itemID, $newNode); + } else { + // there is a node already, we update it + $child->counter += $pathCount; + $currentNode = $child; + } + } + } + } + + /** + * Method for creating the list of items in the header table, + * in descending order of support. + * @param mixed $mapSupport the frequencies of each item (key: item value: support) + */ + function createHeaderList($mapSupport) + { + // create an array to store the header list with + // all the items stored in the map received as parameter + $this->headerList = array_keys($this->mapItemNodes); + + $sortFunction = function ($id1, $id2) use ($mapSupport) { + // compare the support + $compare = $mapSupport[$id2] - $mapSupport[$id1]; + // if the same frequency, we check the lexical ordering! + // otherwise we use the support + return ($compare == 0) ? ($id1 - $id2) : $compare; + }; + // sort the header table by decreasing order of support + usort($this->headerList, $sortFunction); + + } + + /** + * Method for getting a string representation of the CP-tree + * (to be used for debugging purposes). + * @return string + */ + public function __toString() + { + $temp = "F HeaderList: " . print_r($this->headerList, true) . "\n root: " . $this->root; + return $temp; + } + + +} diff --git a/fpgrowth/Itemset.php b/fpgrowth/Itemset.php new file mode 100644 index 0000000..949b167 --- /dev/null +++ b/fpgrowth/Itemset.php @@ -0,0 +1,148 @@ + + * @copyright 2007-2016 PrestaShop SA + * @license http://opensource.org/licenses/afl-3.0.php Academic Free License (AFL 3.0) + * International Registered Trademark & Property of PrestaShop SA + */ + +namespace fpgrowth; + + +/** + * This class represents an itemset (a set of items) implemented as an array of integers with + * a variable to store the support count of the itemset. + * + * @author Jesus Gazol + */ +class Itemset +{ + /** the array of items **/ + public $itemset = []; + + /** the support of this itemset */ + public $support = 0; + + public function __construct($itemset = false) + { + if ($itemset != false) { + $this->itemset = $itemset; + } + } + + /** + * Get the items as array + * @return mixed the items + */ + public function getItems() + { + return $this->itemset; + } + + /** + * Get the support of this itemset + */ + public function getAbsoluteSupport() + { + return $this->support; + } + + /** + * Get the size of this itemset + */ + public function size() + { + return count($this->itemset); + } + + /** + * Get the item at a given position in this itemset + * @param $position + * @return mixed + */ + public function get($position) + { + return $this->itemset[$position]; + } + + /** + * Set the support of this itemset + * @param $support int the support + */ + public function setAbsoluteSupport($support) + { + $this->support = $support; + } + + /** + * Increase the support of this itemset by 1 + */ + public function increaseTransactionCount() + { + $this->support++; + } + + /** + * This method return an itemset containing items that are included + * in this itemset and in a given itemset + * @param $itemset2 Itemset the given itemset + * @return Itemset the new itemset + */ + public function intersection($itemset2) + { + $resItemset = new Itemset(); + $resItemset->itemset = $this->intersectTwoSortedArrays($this->getItems(), $itemset2->getItems()); + return $resItemset; + } + + /** + * Intersection of two sorted arrays + * @param $array1 + * @param $array2 + * @return array + */ + public function intersectTwoSortedArrays($array1, $array2) + { + // create a new array having the smallest size between the two arrays + $newArraySize = (count($array1) < count($array2)) ? count($array1) : count($array2); + $newArray = new SplFixedArray($newArraySize); + + $pos1 = 0; + $pos2 = 0; + $posNewArray = 0; + while ($pos1 < count($array1) && $pos2 < count($array2)) { + if ($array1[$pos1] < $array2[$pos2]) { + $pos1++; + } else if ($array2[$pos2] < $array1[$pos1]) { + $pos2++; + } else { // if they are the same + $newArray[$posNewArray] = $array1[$pos1]; + $posNewArray++; + $pos1++; + $pos2++; + } + } + // return the subrange of the new array that is full. + return array_slice($newArray, 0, $posNewArray); + } + + +} diff --git a/fpgrowth/Itemsets.php b/fpgrowth/Itemsets.php new file mode 100644 index 0000000..29f6fb5 --- /dev/null +++ b/fpgrowth/Itemsets.php @@ -0,0 +1,125 @@ + + * @copyright 2007-2016 PrestaShop SA + * @license http://opensource.org/licenses/afl-3.0.php Academic Free License (AFL 3.0) + * International Registered Trademark & Property of PrestaShop SA + */ + +namespace fpgrowth; + + +/** + * This class represents a set of itemsets, where an itemset is an array of integers + * with an associated support count. Itemsets are ordered by size. For + * example, level 1 means itemsets of size 1 (that contains 1 item). + */ +class Itemsets +{ + /** We store the itemsets in a list named "levels". + * Position i in "levels" contains the list of itemsets of size i */ + private $levels = []; + /** the total number of itemsets **/ + private $itemsetsCount = 0; + /** a name that we give to these itemsets (e.g. "frequent itemsets") */ + private $name; + + /** + * Constructor + * @param string $name the name of these itemsets + */ + public function __construct($name) + { + $this->name = $name; + $this->levels[] = []; // We create an empty level 0 by + // default. + } + + /* + */ + public function printItemsets() + { + print(" ------- {$this->name} -------\n"); + $patternCount = 0; + $levelCount = 0; + // for each level (a level is a set of itemsets having the same number of items) + foreach ($this->levels as $level) { + // print how many items are contained in this level + print(" L {$levelCount} \n"); + // for each itemset + foreach ($level as $itemset) { + + // print the itemset + print(" pattern {$patternCount}: "); + $itemset->print(); + // print the support of this itemset + print("support : " . $itemset->getAbsoluteSupport() . "\n"); + $patternCount++; + } + $levelCount++; + } + print(" --------------------------------\n"); + } + + /* + * + */ + public function addItemset($itemset, $k) + { + while (count($this->levels) <= $k) { + $this->levels[] = []; + } + $this->levels[$k][] = $itemset; + $this->itemsetsCount++; + } + + /* + */ + public function getLevels() + { + return $this->levels; + + } + + /* + * + */ + public function getItemsetsCount() + { + return $this->itemsetsCount; + } + + /* + * + */ + public function setName($newName) + { + $this->name = $newName; + } + + /* + * + */ + public function decreaseItemsetCount() + { + $this->itemsetsCount--; + } +} diff --git a/fpgrowth/PrestashopWrapper.php b/fpgrowth/PrestashopWrapper.php new file mode 100644 index 0000000..57a3c61 --- /dev/null +++ b/fpgrowth/PrestashopWrapper.php @@ -0,0 +1,186 @@ + + * @copyright 2007-2016 PrestaShop SA + * @license http://opensource.org/licenses/afl-3.0.php Academic Free License (AFL 3.0) + * International Registered Trademark & Property of PrestaShop SA + */ + +namespace fpgrowth; + +require_once "AlgoFPGrowth.php"; + +/** + * Class that wraps fpgrowth functionality for prestashop + * + * User: jesus gazol + * Date: 04/07/16 + * Time: 14:37 + */ +class PrestashopWrapper +{ + /** + * Number of transactions we are going to pull from the database + */ + const TRANSACTION_LIMIT = 10000; + const CART_LIMIT = 5000; + + /** + * Get the product counters from prestashop db + * @return array + */ + public function getProductCounters() + { + $sql = " + SELECT id_product, count(0) as counter + FROM ps_cart_product + GROUP BY id_product + ORDER BY id_cart DESC + LIMIT " . self::TRANSACTION_LIMIT; + $rows = \Db::getInstance(_PS_USE_SQL_SLAVE_)->executeS($sql); + $productDict = []; + foreach ($rows as $row) { + $productDict["{$row['id_product']}"] = $row['counter']; + } + return $productDict; + } + + /** + * Get the transactions from prestashop db + * @return mixed + */ + public function getTransactionsDb() + { + $sql = " + SELECT group_concat(id_product separator ' ') as transaction + FROM ps_cart_product + GROUP BY id_cart + ORDER BY id_cart DESC + LIMIT " . self::CART_LIMIT; + $rows = \Db::getInstance(_PS_USE_SQL_SLAVE_)->executeS($sql); + + $transactions = []; + foreach ($rows as $row) { + $transactions[] = explode(" ", $row['transaction']); + } + return $transactions; + } + + /** + * Run product association fpgrowth algorithm + * @param $minSupport float minimum support + * @return mixed Levels of association rules found + */ + public function getProductAssociationRules($minSupport = 0.02) + { + $algo = new AlgoFPGrowth(); + $transactions = $this->getTransactionsDb(); + $patterns = $algo->runAlgorithm($transactions, $minSupport, $this->getProductCounters(), count($transactions)); + return $patterns->getLevels(); + + } + + /** + * Save the product association rules, we are going to use only level 2 + * @param $levels mixed Association rules derived + */ + public function saveProductAssociationRules($levels) + { + if (count($levels[2]) > 0) { + #Truncate table + \Db::getInstance()->execute("TRUNCATE TABLE ps_crossselling_pair"); + #Add new rules + $sql = "INSERT INTO ps_crossselling_pair + (`id_product_main`, + `id_product_related`, + `support`) + VALUES "; + foreach ($levels[2] as $rule) { + $items = $rule->getItems(); + $sqlValues[] = "({$items[0]},{$items[1]}," . $rule->getAbsoluteSupport() . ")"; + #Add also reversed order + $sqlValues[] = "({$items[1]},{$items[0]}," . $rule->getAbsoluteSupport() . ")"; + } + $sql .= join(",", $sqlValues); + + return \Db::getInstance()->execute($sql); + } + return false; + } + + /** + * Function to create the table needed in the database + */ + public static function createTable() + { + $sql = "CREATE TABLE " . _DB_PREFIX_ . "crossselling_pair ( + `id_crossselling_pair` INT NOT NULL AUTO_INCREMENT, + `id_product_main` INT NOT NULL, + `id_product_related` INT NOT NULL, + `support` INT NULL DEFAULT 0, + PRIMARY KEY (`id_crossselling_pair`), + INDEX `idx_pmain` (`id_product_main` ASC, `support` ASC)) + "; + \Db::getInstance()->execute($sql); + } + + /** + * Function that injects data into test database, do not use in production + */ + public static function injectDummyCarts() + { + $totalCarts = 100; + $totalProducts = 3; + $idProducts = range(1, 7); + $idCustomers = range(1, 10); + #Create cart + for ($i = 1; $i < $totalCarts; $i++) { + $cart = new \Cart(); + $cart->id_shop_group = 1; + $cart->id_shop = 1; + $cart->id_customer = array_rand($idCustomers); + $cart->id_carrier = 2; + $cart->id_address_delivery = 1; + $cart->id_address_invoice = 1; + $cart->id_currency = 1; + $cart->id_lang = 1; + $cart->secure_key = ""; + // Save new cart + $cart->add(); + #Insert products + for ($j = 0; $j <= $totalProducts; $j++) { + $sql = "INSERT INTO `prestashop`.`ps_cart_product` + (`id_cart`, + `id_product`, + `id_address_delivery`, + `id_shop`, + `id_product_attribute`, + `id_customization`, + `quantity` + ) + VALUES + (" . $cart->id . "," . $idProducts[array_rand($idProducts)] . ", 1, 1, 0, 0, 1 )"; + \Db::getInstance()->executeS($sql); + } + } + + } +} \ No newline at end of file diff --git a/fpgrowth/TransactionFileGenerator.php b/fpgrowth/TransactionFileGenerator.php new file mode 100644 index 0000000..330d602 --- /dev/null +++ b/fpgrowth/TransactionFileGenerator.php @@ -0,0 +1,45 @@ + + * @copyright 2007-2016 PrestaShop SA + * @license http://opensource.org/licenses/afl-3.0.php Academic Free License (AFL 3.0) + * International Registered Trademark & Property of PrestaShop SA + */ + +namespace fpgrowth; + + +class TransactionFileGenerator extends \SplFileObject +{ + + function current() + { + $line = trim(parent::current()); + if (strlen($line) > 0) { + return explode(" ", $line); + } else { + return []; + } + + } + + +} \ No newline at end of file diff --git a/fpgrowth/tests/AlgoFPGrowthTest.php b/fpgrowth/tests/AlgoFPGrowthTest.php new file mode 100644 index 0000000..b717278 --- /dev/null +++ b/fpgrowth/tests/AlgoFPGrowthTest.php @@ -0,0 +1,112 @@ + + * @copyright 2007-2016 PrestaShop SA + * @license http://opensource.org/licenses/afl-3.0.php Academic Free License (AFL 3.0) + * International Registered Trademark & Property of PrestaShop SA + */ + +require_once("../AlgoFPGrowth.php"); + +/** + * Generated by PHPUnit_SkeletonGenerator on 2016-07-01 at 12:42:07. + */ +class AlgoFPGrowthTest extends PHPUnit_Framework_TestCase +{ + /** + * @var fpgrowth\AlgoFPGrowth + */ + protected $object; + + private $filename; + + /** + * Sets up the fixture, for example, opens a network connection. + * This method is called before a test is executed. + */ + protected function setUp() + { + $this->object = new fpgrowth\AlgoFPGrowth; + $this->filename = "sample.txt"; + } + + /** + * Tears down the fixture, for example, closes a network connection. + * This method is called after a test is executed. + */ + protected function tearDown() + { + } + + /** + * @covers AlgoFPGrowth::runAlgorithm + * @todo Implement testRunAlgorithm(). + */ + public function testRunAlgorithm() + { + $transactionData = new \fpgrowth\TransactionFileGenerator($this->filename); + $minSupport = 0.2; + $patterns = $this->object->runAlgorithm($transactionData, $minSupport); + $levels = $patterns->getLevels(); + $this->assertEquals($levels[3][0]->itemset, ['1', '2', '3']); + + } + + public function testScanDatabaseToDetermineFrequencyOfSingleItems() + { + $transactionData = new \fpgrowth\TransactionFileGenerator($this->filename); + $mapSupport = $this->object->scanDatabaseToDetermineFrequencyOfSingleItems($transactionData); + $expected = [ + '3' => 4, + '4' => 3, + '1' => 4, + '2' => 4, + '5' => 1 + ]; + $this->assertEquals($mapSupport, $expected); + } + + public function testSortTransaction() + { + $testMap = [ + '3' => 4, + '4' => 3, + '1' => 4, + '2' => 4, + '5' => 1 + ]; + $transaction = ['5', '4', '1']; + $this->object->sortTransaction($transaction, $testMap); + $this->assertEquals($transaction, ['1', '4', '5']); + } + + public function testOnePathFile() + { + + $transactionData = new \fpgrowth\TransactionFileGenerator("sample_onepath.txt"); + $patterns = $this->object->runAlgorithm($transactionData, 0.2); + $levels = $patterns->getLevels(); + $this->assertEquals($levels[3][0]->itemset, ['a', 'b', 'c']); + + } + + +} diff --git a/fpgrowth/tests/sample.txt b/fpgrowth/tests/sample.txt new file mode 100644 index 0000000..cd5016f --- /dev/null +++ b/fpgrowth/tests/sample.txt @@ -0,0 +1,7 @@ +3 4 +3 4 +1 2 +1 2 3 4 +1 2 +1 2 3 +5 \ No newline at end of file diff --git a/fpgrowth/tests/sample_onepath.txt b/fpgrowth/tests/sample_onepath.txt new file mode 100644 index 0000000..b82c4b2 --- /dev/null +++ b/fpgrowth/tests/sample_onepath.txt @@ -0,0 +1,5 @@ +a b c +a b c +a b c +a b c +a b c