Skip to content

Commit ed525e9

Browse files
added files that are featured in the blog post
1 parent 17ba8af commit ed525e9

File tree

5 files changed

+495
-94
lines changed

5 files changed

+495
-94
lines changed

app/ProductSimilarity.php

+92
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace App;
4+
5+
use Exception;
6+
7+
class ProductSimilarity
8+
{
9+
protected $products = [];
10+
protected $featureWeight = 1;
11+
protected $priceWeight = 1;
12+
protected $categoryWeight = 1;
13+
protected $priceHighRange = 1000;
14+
15+
public function __construct(array $products)
16+
{
17+
$this->products = $products;
18+
$this->priceHighRange = max(array_column($products, 'price'));
19+
}
20+
21+
public function setFeatureWeight(float $weight): void
22+
{
23+
$this->featureWeight = $weight;
24+
}
25+
26+
public function setPriceWeight(float $weight): void
27+
{
28+
$this->priceWeight = $weight;
29+
}
30+
31+
public function setCategoryWeight(float $weight): void
32+
{
33+
$this->categoryWeight = $weight;
34+
}
35+
36+
public function calculateSimilarityMatrix(): array
37+
{
38+
$matrix = [];
39+
40+
foreach ($this->products as $product) {
41+
42+
$similarityScores = [];
43+
44+
foreach ($this->products as $_product) {
45+
if ($product->id === $_product->id) {
46+
continue;
47+
}
48+
$similarityScores['product_id_' . $_product->id] = $this->calculateSimilarityScore($product, $_product);
49+
}
50+
$matrix['product_id_' . $product->id] = $similarityScores;
51+
}
52+
return $matrix;
53+
}
54+
55+
public function getProductsSortedBySimularity(int $productId, array $matrix): array
56+
{
57+
$similarities = $matrix['product_id_' . $productId] ?? null;
58+
$sortedProducts = [];
59+
60+
if (is_null($similarities)) {
61+
throw new Exception('Can\'t find product with that ID.');
62+
}
63+
arsort($similarities);
64+
65+
foreach ($similarities as $productIdKey => $similarity) {
66+
$id = intval(str_replace('product_id_', '', $productIdKey));
67+
$products = array_filter($this->products, function ($product) use ($id) { return $product->id === $id; });
68+
if (! count($products)) {
69+
continue;
70+
}
71+
$product = $products[array_keys($products)[0]];
72+
$product->similarity = $similarity;
73+
$sortedProducts[] = $product;
74+
}
75+
return $sortedProducts;
76+
}
77+
78+
protected function calculateSimilarityScore($productA, $productB)
79+
{
80+
$productAFeatures = implode('', get_object_vars($productA->features));
81+
$productBFeatures = implode('', get_object_vars($productB->features));
82+
83+
return array_sum([
84+
(Similarity::hamming($productAFeatures, $productBFeatures) * $this->featureWeight),
85+
(Similarity::euclidean(
86+
Similarity::minMaxNorm([$productA->price], 0, $this->priceHighRange),
87+
Similarity::minMaxNorm([$productB->price], 0, $this->priceHighRange)
88+
) * $this->priceWeight),
89+
(Similarity::jaccard($productA->categories, $productB->categories) * $this->categoryWeight)
90+
]) / ($this->featureWeight + $this->priceWeight + $this->categoryWeight);
91+
}
92+
}

app/Similarity.php

+64
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace App;
4+
5+
class Similarity
6+
{
7+
public static function hamming(string $string1, string $string2, bool $returnDistance = false): float
8+
{
9+
$a = str_pad($string1, strlen($string2) - strlen($string1), ' ');
10+
$b = str_pad($string2, strlen($string1) - strlen($string2), ' ');
11+
$distance = count(array_diff_assoc(str_split($a), str_split($b)));
12+
13+
if ($returnDistance) {
14+
return $distance;
15+
}
16+
return (strlen($a) - $distance) / strlen($a);
17+
}
18+
19+
public static function euclidean(array $array1, array $array2, bool $returnDistance = false): float
20+
{
21+
$a = $array1;
22+
$b = $array2;
23+
$set = [];
24+
25+
foreach ($a as $index => $value) {
26+
$set[] = $value - $b[$index] ?? 0;
27+
}
28+
29+
$distance = sqrt(array_sum(array_map(function ($x) { return pow($x, 2); }, $set)));
30+
31+
if ($returnDistance) {
32+
return $distance;
33+
}
34+
// doesn't work well with distances larger than 1
35+
// return 1 / (1 + $distance);
36+
// so we'll use angular similarity instead
37+
return 1 - $distance;
38+
}
39+
40+
public static function jaccard(string $string1, string $string2, string $separator = ','): float
41+
{
42+
$a = explode($separator, $string1);
43+
$b = explode($separator, $string2);
44+
$intersection = array_unique(array_intersect($a, $b));
45+
$union = array_unique(array_merge($a, $b));
46+
47+
return count($intersection) / count($union);
48+
}
49+
50+
public static function minMaxNorm(array $values, $min = null, $max = null): array
51+
{
52+
$norm = [];
53+
$min = $min ?? min($values);
54+
$max = $max ?? max($values);
55+
56+
foreach ($values as $value) {
57+
$numerator = $value - $min;
58+
$denominator = $max - $min;
59+
$minMaxNorm = $numerator / $denominator;
60+
$norm[] = $minMaxNorm;
61+
}
62+
return $norm;
63+
}
64+
}

resources/views/welcome.blade.php

+63-93
Original file line numberDiff line numberDiff line change
@@ -1,99 +1,69 @@
11
<!doctype html>
2-
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
3-
<head>
4-
<meta charset="utf-8">
5-
<meta name="viewport" content="width=device-width, initial-scale=1">
6-
7-
<title>Laravel</title>
8-
9-
<!-- Fonts -->
10-
<link href="https://fonts.googleapis.com/css?family=Nunito:200,600" rel="stylesheet">
11-
12-
<!-- Styles -->
13-
<style>
14-
html, body {
15-
background-color: #fff;
16-
color: #636b6f;
17-
font-family: 'Nunito', sans-serif;
18-
font-weight: 200;
19-
height: 100vh;
20-
margin: 0;
21-
}
22-
23-
.full-height {
24-
height: 100vh;
25-
}
26-
27-
.flex-center {
28-
align-items: center;
29-
display: flex;
30-
justify-content: center;
31-
}
32-
33-
.position-ref {
34-
position: relative;
35-
}
36-
37-
.top-right {
38-
position: absolute;
39-
right: 10px;
40-
top: 18px;
41-
}
42-
43-
.content {
44-
text-align: center;
45-
}
46-
47-
.title {
48-
font-size: 84px;
49-
}
50-
51-
.links > a {
52-
color: #636b6f;
53-
padding: 0 25px;
54-
font-size: 13px;
55-
font-weight: 600;
56-
letter-spacing: .1rem;
57-
text-decoration: none;
58-
text-transform: uppercase;
59-
}
60-
61-
.m-b-md {
62-
margin-bottom: 30px;
63-
}
64-
</style>
65-
</head>
66-
<body>
67-
<div class="flex-center position-ref full-height">
68-
@if (Route::has('login'))
69-
<div class="top-right links">
70-
@auth
71-
<a href="{{ url('/home') }}">Home</a>
72-
@else
73-
<a href="{{ route('login') }}">Login</a>
74-
75-
@if (Route::has('register'))
76-
<a href="{{ route('register') }}">Register</a>
77-
@endif
78-
@endauth
79-
</div>
80-
@endif
81-
82-
<div class="content">
83-
<div class="title m-b-md">
84-
Laravel
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
6+
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous">
7+
<title>Recommender System in Laravel</title>
8+
<style>
9+
.large-product-image {
10+
width: auto;
11+
height: 200px;
12+
}
13+
</style>
14+
</head>
15+
<body>
16+
<div class="container">
17+
<div class="row mb-5" style="border-bottom: 1px solid #ccc">
18+
<div class="col text-center">
19+
<img class="p-3" style="height: 80px; width: auto; border-top: 1px solid #ccc; background-color: #f7f7f7" src="{{ $selectedProduct->image }}" alt="Product Image">
20+
@foreach ($products as $product)
21+
<a href="/?id={{ $product->id }}" style="text-decoration: none;">
22+
<img class="p-3" style="height: 80px; width: auto;" src="{{ $product->image }}" alt="Product Image">
23+
</a>
24+
@endforeach
25+
</div>
26+
</div>
27+
<div class="row">
28+
<div class="offset-3 col-6">
29+
<h5>Selected Product</h5>
30+
</div>
31+
</div>
32+
<div class="row mb-5">
33+
<div class="offset-3 col-6">
34+
<div class="card">
35+
<div class="text-center" style="background-color: #ccc">
36+
<img class="large-product-image" src="{{ $selectedProduct->image }}" alt="Product Image">
37+
</div>
38+
<div class="card-body">
39+
<p class="card-text text-muted">{{ $selectedProduct->name }} (${{ $selectedProduct->price }})</p>
40+
</div>
8541
</div>
86-
87-
<div class="links">
88-
<a href="https://laravel.com/docs">Docs</a>
89-
<a href="https://laracasts.com">Laracasts</a>
90-
<a href="https://laravel-news.com">News</a>
91-
<a href="https://blog.laravel.com">Blog</a>
92-
<a href="https://nova.laravel.com">Nova</a>
93-
<a href="https://forge.laravel.com">Forge</a>
94-
<a href="https://github.com/laravel/laravel">GitHub</a>
42+
</div>
43+
</div>
44+
<div class="row">
45+
<div class="offset-3 col-6">
46+
<h5>Similar Products</h5>
47+
</div>
48+
</div>
49+
@foreach ($products as $product)
50+
<div class="row mb-5">
51+
<div class="offset-3 col-6">
52+
<div class="card">
53+
<div class="text-center" style="background-color: #ccc">
54+
<img class="large-product-image" src="{{ $product->image }}" alt="Product Image">
55+
</div>
56+
<div class="card-body">
57+
<h5 class="card-title">Similarity: {{ round($product->similarity * 100, 1) }}%</h5>
58+
<p class="card-text text-muted">{{ $product->name }} (${{ $product->price }})</p>
59+
</div>
9560
</div>
9661
</div>
9762
</div>
98-
</body>
63+
@endforeach
64+
</div>
65+
<script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
66+
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.3/umd/popper.min.js" integrity="sha384-ZMP7rVo3mIykV+2+9J3UJ46jBk0WLaUAdn689aCwoqbBJiSnjAK/l8WvCWPIPm49" crossorigin="anonymous"></script>
67+
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.min.js" integrity="sha384-ChfqqxuZUCnJSK3+MXmPNIyE6ZbWh2IMqE241rYiqJxyMiZ6OW/JmZQ5stwEULTy" crossorigin="anonymous"></script>
68+
</body>
9969
</html>

routes/web.php

+14-1
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,18 @@
1212
*/
1313

1414
Route::get('/', function () {
15-
return view('welcome');
15+
$products = json_decode(file_get_contents(storage_path('data/products-data.json')));
16+
$selectedId = intval(app('request')->input('id') ?? '8');
17+
$selectedProduct = $products[0];
18+
19+
$selectedProducts = array_filter($products, function ($product) use ($selectedId) { return $product->id === $selectedId; });
20+
if (count($selectedProducts)) {
21+
$selectedProduct = $selectedProducts[array_keys($selectedProducts)[0]];
22+
}
23+
24+
$productSimilarity = new App\ProductSimilarity($products);
25+
$similarityMatrix = $productSimilarity->calculateSimilarityMatrix();
26+
$products = $productSimilarity->getProductsSortedBySimularity($selectedId, $similarityMatrix);
27+
28+
return view('welcome', compact('selectedId', 'selectedProduct', 'products'));
1629
});

0 commit comments

Comments
 (0)