From 732a6a255b352a3c9d25c4c1b214a45c196c01aa Mon Sep 17 00:00:00 2001 From: Tiago Aparecido Cavalcante Pereira Date: Thu, 28 Jan 2021 07:36:08 -0300 Subject: [PATCH] update --- composer.json | 34 +++++ phpunit.xml | 12 ++ src/Collaborative/Base.php | 64 +++++++++ src/Collaborative/EuclideanCollaborative.php | 138 +++++++++++++++++++ src/Collaborative/RankingCollaborative.php | 85 ++++++++++++ src/Configuration/StandardKey.php | 9 ++ src/Creator/CollaborativeCreator.php | 32 +++++ src/Factories/CollaborativeFactory.php | 21 +++ src/Interfaces/CollaborativeInterface.php | 9 ++ src/Recommend.php | 50 +++++++ src/Traits/OperationTrait.php | 39 ++++++ tests/DataArrayTrait.php | 114 +++++++++++++++ tests/RecommendTest.php | 57 ++++++++ 13 files changed, 664 insertions(+) create mode 100644 composer.json create mode 100644 phpunit.xml create mode 100644 src/Collaborative/Base.php create mode 100644 src/Collaborative/EuclideanCollaborative.php create mode 100644 src/Collaborative/RankingCollaborative.php create mode 100644 src/Configuration/StandardKey.php create mode 100644 src/Creator/CollaborativeCreator.php create mode 100644 src/Factories/CollaborativeFactory.php create mode 100644 src/Interfaces/CollaborativeInterface.php create mode 100644 src/Recommend.php create mode 100644 src/Traits/OperationTrait.php create mode 100644 tests/DataArrayTrait.php create mode 100644 tests/RecommendTest.php diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..484c160 --- /dev/null +++ b/composer.json @@ -0,0 +1,34 @@ +{ + "name": "tigo/recommendation", + "description": "collaborative filtering recommender systems", + "license": "MIT", + "keywords": [ + "recommendation", + "collaborative filtering", + "euclidean distance", + "recommender system", + "recommendation system", + "recommendation algorithm", + "recommender" + ], + "authors": [ + { + "name": "Tiago A C Pereira", + "email": "tiagocavalcante57@gmail.com" + } + ], + "require": { + "php": ">=7.0" + }, + "autoload": { + "psr-4": { + "Tigo\\Recommendation\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { "Tigo\\Recommendation\\Tests\\": "tests" } + }, + "require-dev": { + "phpunit/phpunit": "^9.5" + } +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..cee3e1b --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,12 @@ + + + + + + tests + + + \ No newline at end of file diff --git a/src/Collaborative/Base.php b/src/Collaborative/Base.php new file mode 100644 index 0000000..11cdeee --- /dev/null +++ b/src/Collaborative/Base.php @@ -0,0 +1,64 @@ + + */ +abstract class Base extends StandardKey implements CollaborativeInterface +{ + + /** + * User rated product. + * @var array + */ + protected $product = []; + + /** + * Product rated by other users. + * @var array + */ + protected $other = []; + + /** + * Get rated product. + * @param array $table + * @param mixed $user + * + * @return [type] + */ + protected function ratedProduct($table, $user) + { + foreach($table as $item){ + $item[self::USER_ID] == $user ? $this->product[] = $item : $this->other[] = $item; + } + } + + + /** + * Get filter rating. + * Remove product that the user has rated. + * @param array $data + * + * @return array + */ + protected function filterRating($data) + { + $myRank = $data; + $rank = $myRank; + for($i = 0; $i < count($myRank); $i++){ + foreach($this->product as $item){ + if($item[self::PRODUCT_ID] == key($myRank)) + unset($rank[key($myRank)]); // remove product + } + next($myRank); + } + arsort($rank); + return $rank; + } + +} \ No newline at end of file diff --git a/src/Collaborative/EuclideanCollaborative.php b/src/Collaborative/EuclideanCollaborative.php new file mode 100644 index 0000000..70e0fe1 --- /dev/null +++ b/src/Collaborative/EuclideanCollaborative.php @@ -0,0 +1,138 @@ + + */ +class EuclideanCollaborative extends Base +{ + + use OperationTrait; + + /** + * Get recommend. + * @param array $table + * @param mixed $user + * @param mixed $score + * + * @return array + */ + public function recommend($table, $user, $score = 0) + { + $data = $this->average($table, $user, $score); + return $this->filterRating($data); + } + + /** + * Get users who rated the same product. + * @param array $table + * @param mixed $user + * @param mixed $score + * + * @return array + */ + private function userRated($table, $user, $score) + { + $this->ratedProduct($table, $user); + $rated = []; //get user rating + foreach($this->product as $myProduct){ + foreach($this->other as $item){ + if($myProduct[self::PRODUCT_ID] == $item[self::PRODUCT_ID]){ + if($myProduct[self::SCORE] >= $score && $item[self::SCORE] >= $score){ + if(!in_array($item[self::USER_ID],$rated)) // check if user already exists + $rated[] = $item[self::USER_ID]; //add user + } + } + } + } + return $rated; + } + + /** + * Get operation|using part of the euclidean formula (p-q). + * @param array $table + * @param mixed $user + * @param mixed $score + * + * @return array + */ + private function operation($table, $user, $score) + { + $rated = $this->userRated($table, $user, $score); + $data = []; + foreach ($this->product as $myProduct){ + for($i = 0; $i < count($rated) ; $i++){ + foreach($this->other as $itemOther){ + if($itemOther[self::USER_ID] == $rated[$i] && + $myProduct[self::PRODUCT_ID] == $itemOther[self::PRODUCT_ID] + && $myProduct[self::SCORE] >= $score && $itemOther[self::SCORE] >= $score){ + $data[$itemOther[self::USER_ID]][$myProduct[self::PRODUCT_ID]] = abs($itemOther[self::SCORE] - $myProduct[self::SCORE]); + } + } + } + } + return $data; + } + + /** + * Using the metric distance formula and convert value to percentage. + * @param array $table + * @param mixed $user + * @param mixed $score + * + * @return array + */ + private function metricDistance($table, $user, $score) + { + $data = $this->operation($table, $user, $score); + $element = []; + foreach($data as $item){ + foreach($item as $value){ + if(!isset($element[key($data)])) + $element[key($data)] = 0; + $element[key($data)] += pow($value,2); + } + $similarity = round(sqrt($element[key($data)]),2); //similarity rate + $element[key($data)] = round(1/(1 + $similarity), 2); //convert value + next($data); + } + return $element; + } + + + /** + * Get weighted average. + * @param array $table + * @param mixed $user + * @param mixed $score + * + * @return array + */ + private function average($table, $user, $score) + { + $metric = $this->metricDistance($table, $user, $score); + $similarity = []; + $element = []; + foreach($metric as $itemMetric){ + foreach($this->other as $itemOther){ + if($itemOther[self::USER_ID] == key($metric) && $itemOther[self::SCORE] >= $score){ + if(!isset($element[$itemOther[self::PRODUCT_ID]])){ + $element[$itemOther[self::PRODUCT_ID]] = 0; + $similarity[$itemOther[self::PRODUCT_ID]] = 0; + } + $element[$itemOther[self::PRODUCT_ID]] += ($itemMetric * $itemOther[self::SCORE]); + $similarity[$itemOther[self::PRODUCT_ID]] += $itemMetric; + } + } + next($metric); + } + return $this->division($element,$similarity); + } + +} \ No newline at end of file diff --git a/src/Collaborative/RankingCollaborative.php b/src/Collaborative/RankingCollaborative.php new file mode 100644 index 0000000..9b86b44 --- /dev/null +++ b/src/Collaborative/RankingCollaborative.php @@ -0,0 +1,85 @@ + + */ +class RankingCollaborative extends Base +{ + + /** + * Get Recommend. + * @param array $table + * @param mixed $user + * @param mixed $score + * + * @return array + */ + public function recommend($table, $user, $score = 0) + { + $data = $this->addRating($table, $user, $score); + return $this->filterRating($data); + } + + /** + * Find similar users (Add weight score). + * @param array $table + * @param mixed $user + * + * @return array + */ + private function similarUser($table, $user) + { + $this->ratedProduct($table, $user); //get [product, other] + $similar = []; //get users with similar tastes + $rank = []; + foreach($this->product as $myProduct){ + foreach($this->other as $item){ + if($myProduct[self::PRODUCT_ID] == $item[self::PRODUCT_ID]){ + if($myProduct[self::SCORE] == $item[self::SCORE]){ + if(!isset($similar[$item[self::USER_ID]])) + $similar[$item[self::USER_ID]] = 0; // + $similar[$item[self::USER_ID]] += 1; //assigning weight + } + } + } + } + return $similar; + } + + + /** + * Add Rating | Add a score (+value) for each recommended product. + * @param array $table + * @param mixed $user + * @param mixed $score + * + * @return array + */ + private function addRating($table, $user, $score) + { + $similar = $this->similarUser($table, $user); + $rank = []; + foreach($this->other as $item){ + foreach($similar as $value){ + if($item[self::USER_ID] == key($similar) && $item[self::SCORE] > $score){ + if(!isset($rank[$item[self::PRODUCT_ID]]) ) + $rank[$item[self::PRODUCT_ID]] = 0; //assign value for calculation + $rank[$item[self::PRODUCT_ID]] += $value; //add + } + next($similar); + } + reset($similar); + } + return $rank; + } + +} diff --git a/src/Configuration/StandardKey.php b/src/Configuration/StandardKey.php new file mode 100644 index 0000000..6748e1d --- /dev/null +++ b/src/Configuration/StandardKey.php @@ -0,0 +1,9 @@ +factoryMethod($method, $table, $user, $score); + } + +} \ No newline at end of file diff --git a/src/Factories/CollaborativeFactory.php b/src/Factories/CollaborativeFactory.php new file mode 100644 index 0000000..2b6d5ca --- /dev/null +++ b/src/Factories/CollaborativeFactory.php @@ -0,0 +1,21 @@ +recommend($table, $user, $score); + } +} \ No newline at end of file diff --git a/src/Interfaces/CollaborativeInterface.php b/src/Interfaces/CollaborativeInterface.php new file mode 100644 index 0000000..5d24e2f --- /dev/null +++ b/src/Interfaces/CollaborativeInterface.php @@ -0,0 +1,9 @@ +method = new CollaborativeFactory(); + } + + /** + * Get ranking | collaborative filtering algorithm. + * @param array $table + * @param mixed $user + * @param mixed $score + * + * @return array + */ + public function ranking($table, $user, $score = 0) + { + return $this->method->doFactory(new RankingCollaborative(), $table, $user, $score); + } + + /** + * Get euclidean | collaborative filtering algorithm. + * @param array $table + * @param mixed $user + * @param mixed $score + * + * @return array + */ + public function euclidean($table, $user, $score = 0) + { + return $this->method->doFactory(new EuclideanCollaborative(), $table, $user, $score); + } + +} \ No newline at end of file diff --git a/src/Traits/OperationTrait.php b/src/Traits/OperationTrait.php new file mode 100644 index 0000000..df09691 --- /dev/null +++ b/src/Traits/OperationTrait.php @@ -0,0 +1,39 @@ +10,'B'=>20]; $divisor = ['A'=>2,'B'=>10]; result ['A'=>5, 'B'=>2]. + * @param array $dividend + * @param array $divisor + * + * @return array + */ + public function division($dividend, $divisor) + { + $result = []; + foreach($dividend as $item){ + foreach($divisor as $div){ + if(key($dividend) == key($divisor)){ + if(!isset($result[key($dividend)])) + $result[key($dividend)] = $item; + $result[key($dividend)] = round($result[key($dividend)]/$div,2); + } + next($divisor); + } + reset($divisor); + next($dividend); + } + arsort($result); + return $result; + } + +} \ No newline at end of file diff --git a/tests/DataArrayTrait.php b/tests/DataArrayTrait.php new file mode 100644 index 0000000..00550c9 --- /dev/null +++ b/tests/DataArrayTrait.php @@ -0,0 +1,114 @@ +'A', + 'score'=>1, + 'user_id'=>'Pedro' + ], + [ + 'product_id'=>'B', + 'score'=>0, + 'user_id'=>'Pedro' + ], + [ + 'product_id'=>'A', + 'score'=>0, + 'user_id'=>'Maria' + ], + [ + 'product_id'=>'B', + 'score'=>1, + 'user_id'=>'Maria' + ], + [ + 'product_id'=>'C', + 'score'=>1, + 'user_id'=>'Joaquim' + ], + [ + 'product_id'=>'A', + 'score'=>1, + 'user_id'=>'Joaquim' + ], + [ + 'product_id'=>'A', + 'score'=>1, + 'user_id'=>'Beto' + ], + [ + 'product_id'=>'B', + 'score'=>0, + 'user_id'=>'Luiz' + ], + [ + 'product_id'=>'C', + 'score'=>1, + 'user_id'=>'Beto' + ], + [ + 'product_id'=>'G', + 'score'=>1, + 'user_id'=>'Pedro' + ], + [ + 'product_id'=>'A', + 'score'=>1, + 'user_id'=>'Rui' + ], + [ + 'product_id'=>'B', + 'score'=>1, + 'user_id'=>'Beatriz' + ], + [ + 'product_id'=>'C', + 'score'=>0, + 'user_id'=>'Rui' + ], + [ + 'product_id'=>'G', + 'score'=>1, + 'user_id'=>'Maria' + ], + [ + 'product_id'=>'F', + 'score'=>1, + 'user_id'=>'Beatriz' + ], + [ + 'product_id'=>'B', + 'score'=>0, + 'user_id'=>'Joaquim' + ], + [ + 'product_id'=>'F', + 'score'=>1, + 'user_id'=>'Pedro' + ], + [ + 'product_id'=>'C', + 'score'=>1, + 'user_id'=>'Luana' + ], + [ + 'product_id'=>'F', + 'score'=>1, + 'user_id'=>'Luana' + ], + [ + 'product_id'=>'B', + 'score'=>0, + 'user_id'=>'Luana' + ], + [ + 'product_id'=>'B', + 'score'=>1, + 'user_id'=>'Rui' + ] + ]; +} \ No newline at end of file diff --git a/tests/RecommendTest.php b/tests/RecommendTest.php new file mode 100644 index 0000000..aa0cfa5 --- /dev/null +++ b/tests/RecommendTest.php @@ -0,0 +1,57 @@ +ranking($this->table,'Pedro'); + $maria = $client->ranking($this->table,'Maria'); + $joaquim = $client->ranking($this->table,'Joaquim'); + $beto = $client->ranking($this->table,'Beto'); + $luiz = $client->ranking($this->table,'Luiz'); + $rui = $client->ranking($this->table,'Rui'); + $beatriz = $client->ranking($this->table,'Beatriz'); + $luana = $client->ranking($this->table,'Luana'); + + $this->assertEquals($pedro, ['C'=>5]); + $this->assertEquals($maria, ['F'=>2]); + $this->assertEquals($joaquim, ['F'=>4,'G'=>2]); + $this->assertEquals($beto, ['F'=>2,'G'=>1,'B'=>1]); + $this->assertEquals($luiz, ['A'=>2,'C'=>2,'F'=>2, 'G'=>1]); + $this->assertEquals($rui, ['G'=>2,'F'=>2]); + $this->assertEquals($beatriz, ['A'=>2,'G'=>2,'C'=>1]); + $this->assertEquals($luana, ['A'=>5,'G'=>2]); + } + + public function testEuclideanExpectedResult() + { + $client = new Recommend(); + $pedro = $client->euclidean($this->table,'Pedro'); + $maria = $client->euclidean($this->table,'Maria'); + $joaquim = $client->euclidean($this->table,'Joaquim'); + $beto = $client->euclidean($this->table,'Beto'); + $luiz = $client->euclidean($this->table,'Luiz'); + $rui = $client->euclidean($this->table,'Rui'); + $beatriz = $client->euclidean($this->table,'Beatriz'); + $luana = $client->euclidean($this->table,'Luana'); + + $this->assertEquals($pedro, ['C'=>0.86]); + $this->assertEquals($maria, ['F'=>1,'C'=>0.74]); + $this->assertEquals($joaquim, ['F'=>1,'G'=>1]); + $this->assertEquals($beto, ['F'=>1,'G'=>1,'B'=>0.25]); + $this->assertEquals($luiz, ['A'=>0.83,'C'=>0.8,'F'=>1, 'G'=>1]); + $this->assertEquals($rui, ['G'=>1,'F'=>1]); + $this->assertEquals($beatriz, ['A'=>0.67,'G'=>1,'C'=>0.5]); + $this->assertEquals($luana, ['A'=>0.87,'G'=>1]); + } + + +} \ No newline at end of file