Skip to content
Permalink
Browse files

added files that are featured in the blog post

  • Loading branch information...
oliverlundquist committed Mar 11, 2019
1 parent 17ba8af commit ed525e911ce1240f9bda6cb3c845fe494cc5e243
Showing with 495 additions and 94 deletions.
  1. +92 −0 app/ProductSimilarity.php
  2. +64 −0 app/Similarity.php
  3. +63 −93 resources/views/welcome.blade.php
  4. +14 −1 routes/web.php
  5. +262 −0 storage/data/products-data.json
@@ -0,0 +1,92 @@
<?php declare(strict_types=1);
namespace App;
use Exception;
class ProductSimilarity
{
protected $products = [];
protected $featureWeight = 1;
protected $priceWeight = 1;
protected $categoryWeight = 1;
protected $priceHighRange = 1000;
public function __construct(array $products)
{
$this->products = $products;
$this->priceHighRange = max(array_column($products, 'price'));
}
public function setFeatureWeight(float $weight): void
{
$this->featureWeight = $weight;
}
public function setPriceWeight(float $weight): void
{
$this->priceWeight = $weight;
}
public function setCategoryWeight(float $weight): void
{
$this->categoryWeight = $weight;
}
public function calculateSimilarityMatrix(): array
{
$matrix = [];
foreach ($this->products as $product) {
$similarityScores = [];
foreach ($this->products as $_product) {
if ($product->id === $_product->id) {
continue;
}
$similarityScores['product_id_' . $_product->id] = $this->calculateSimilarityScore($product, $_product);
}
$matrix['product_id_' . $product->id] = $similarityScores;
}
return $matrix;
}
public function getProductsSortedBySimularity(int $productId, array $matrix): array
{
$similarities = $matrix['product_id_' . $productId] ?? null;
$sortedProducts = [];
if (is_null($similarities)) {
throw new Exception('Can\'t find product with that ID.');
}
arsort($similarities);
foreach ($similarities as $productIdKey => $similarity) {
$id = intval(str_replace('product_id_', '', $productIdKey));
$products = array_filter($this->products, function ($product) use ($id) { return $product->id === $id; });
if (! count($products)) {
continue;
}
$product = $products[array_keys($products)[0]];
$product->similarity = $similarity;
$sortedProducts[] = $product;
}
return $sortedProducts;
}
protected function calculateSimilarityScore($productA, $productB)
{
$productAFeatures = implode('', get_object_vars($productA->features));
$productBFeatures = implode('', get_object_vars($productB->features));
return array_sum([
(Similarity::hamming($productAFeatures, $productBFeatures) * $this->featureWeight),
(Similarity::euclidean(
Similarity::minMaxNorm([$productA->price], 0, $this->priceHighRange),
Similarity::minMaxNorm([$productB->price], 0, $this->priceHighRange)
) * $this->priceWeight),
(Similarity::jaccard($productA->categories, $productB->categories) * $this->categoryWeight)
]) / ($this->featureWeight + $this->priceWeight + $this->categoryWeight);
}
}
@@ -0,0 +1,64 @@
<?php declare(strict_types=1);
namespace App;
class Similarity
{
public static function hamming(string $string1, string $string2, bool $returnDistance = false): float
{
$a = str_pad($string1, strlen($string2) - strlen($string1), ' ');
$b = str_pad($string2, strlen($string1) - strlen($string2), ' ');
$distance = count(array_diff_assoc(str_split($a), str_split($b)));
if ($returnDistance) {
return $distance;
}
return (strlen($a) - $distance) / strlen($a);
}
public static function euclidean(array $array1, array $array2, bool $returnDistance = false): float
{
$a = $array1;
$b = $array2;
$set = [];
foreach ($a as $index => $value) {
$set[] = $value - $b[$index] ?? 0;
}
$distance = sqrt(array_sum(array_map(function ($x) { return pow($x, 2); }, $set)));
if ($returnDistance) {
return $distance;
}
// doesn't work well with distances larger than 1
// return 1 / (1 + $distance);
// so we'll use angular similarity instead
return 1 - $distance;
}
public static function jaccard(string $string1, string $string2, string $separator = ','): float
{
$a = explode($separator, $string1);
$b = explode($separator, $string2);
$intersection = array_unique(array_intersect($a, $b));
$union = array_unique(array_merge($a, $b));
return count($intersection) / count($union);
}
public static function minMaxNorm(array $values, $min = null, $max = null): array
{
$norm = [];
$min = $min ?? min($values);
$max = $max ?? max($values);
foreach ($values as $value) {
$numerator = $value - $min;
$denominator = $max - $min;
$minMaxNorm = $numerator / $denominator;
$norm[] = $minMaxNorm;
}
return $norm;
}
}
@@ -1,99 +1,69 @@
<!doctype html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">

<title>Laravel</title>

<!-- Fonts -->
<link href="https://fonts.googleapis.com/css?family=Nunito:200,600" rel="stylesheet">

<!-- Styles -->
<style>
html, body {
background-color: #fff;
color: #636b6f;
font-family: 'Nunito', sans-serif;
font-weight: 200;
height: 100vh;
margin: 0;
}
.full-height {
height: 100vh;
}
.flex-center {
align-items: center;
display: flex;
justify-content: center;
}
.position-ref {
position: relative;
}
.top-right {
position: absolute;
right: 10px;
top: 18px;
}
.content {
text-align: center;
}
.title {
font-size: 84px;
}
.links > a {
color: #636b6f;
padding: 0 25px;
font-size: 13px;
font-weight: 600;
letter-spacing: .1rem;
text-decoration: none;
text-transform: uppercase;
}
.m-b-md {
margin-bottom: 30px;
}
</style>
</head>
<body>
<div class="flex-center position-ref full-height">
@if (Route::has('login'))
<div class="top-right links">
@auth
<a href="{{ url('/home') }}">Home</a>
@else
<a href="{{ route('login') }}">Login</a>

@if (Route::has('register'))
<a href="{{ route('register') }}">Register</a>
@endif
@endauth
</div>
@endif

<div class="content">
<div class="title m-b-md">
Laravel
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous">
<title>Recommender System in Laravel</title>
<style>
.large-product-image {
width: auto;
height: 200px;
}
</style>
</head>
<body>
<div class="container">
<div class="row mb-5" style="border-bottom: 1px solid #ccc">
<div class="col text-center">
<img class="p-3" style="height: 80px; width: auto; border-top: 1px solid #ccc; background-color: #f7f7f7" src="{{ $selectedProduct->image }}" alt="Product Image">
@foreach ($products as $product)
<a href="/?id={{ $product->id }}" style="text-decoration: none;">
<img class="p-3" style="height: 80px; width: auto;" src="{{ $product->image }}" alt="Product Image">
</a>
@endforeach
</div>
</div>
<div class="row">
<div class="offset-3 col-6">
<h5>Selected Product</h5>
</div>
</div>
<div class="row mb-5">
<div class="offset-3 col-6">
<div class="card">
<div class="text-center" style="background-color: #ccc">
<img class="large-product-image" src="{{ $selectedProduct->image }}" alt="Product Image">
</div>
<div class="card-body">
<p class="card-text text-muted">{{ $selectedProduct->name }} (${{ $selectedProduct->price }})</p>
</div>
</div>

<div class="links">
<a href="https://laravel.com/docs">Docs</a>
<a href="https://laracasts.com">Laracasts</a>
<a href="https://laravel-news.com">News</a>
<a href="https://blog.laravel.com">Blog</a>
<a href="https://nova.laravel.com">Nova</a>
<a href="https://forge.laravel.com">Forge</a>
<a href="https://github.com/laravel/laravel">GitHub</a>
</div>
</div>
<div class="row">
<div class="offset-3 col-6">
<h5>Similar Products</h5>
</div>
</div>
@foreach ($products as $product)
<div class="row mb-5">
<div class="offset-3 col-6">
<div class="card">
<div class="text-center" style="background-color: #ccc">
<img class="large-product-image" src="{{ $product->image }}" alt="Product Image">
</div>
<div class="card-body">
<h5 class="card-title">Similarity: {{ round($product->similarity * 100, 1) }}%</h5>
<p class="card-text text-muted">{{ $product->name }} (${{ $product->price }})</p>
</div>
</div>
</div>
</div>
</body>
@endforeach
</div>
<script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
<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>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.min.js" integrity="sha384-ChfqqxuZUCnJSK3+MXmPNIyE6ZbWh2IMqE241rYiqJxyMiZ6OW/JmZQ5stwEULTy" crossorigin="anonymous"></script>
</body>
</html>
@@ -12,5 +12,18 @@
*/
Route::get('/', function () {
return view('welcome');
$products = json_decode(file_get_contents(storage_path('data/products-data.json')));
$selectedId = intval(app('request')->input('id') ?? '8');
$selectedProduct = $products[0];
$selectedProducts = array_filter($products, function ($product) use ($selectedId) { return $product->id === $selectedId; });
if (count($selectedProducts)) {
$selectedProduct = $selectedProducts[array_keys($selectedProducts)[0]];
}
$productSimilarity = new App\ProductSimilarity($products);
$similarityMatrix = $productSimilarity->calculateSimilarityMatrix();
$products = $productSimilarity->getProductsSortedBySimularity($selectedId, $similarityMatrix);
return view('welcome', compact('selectedId', 'selectedProduct', 'products'));
});
Oops, something went wrong.

0 comments on commit ed525e9

Please sign in to comment.
You can’t perform that action at this time.