Passed
Push — develop ( 71cea3...de0e69 )
by Paul
14:33
created

Rating::labels()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 17
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

Changes 0
Metric Value
eloc 12
c 0
b 0
f 0
dl 0
loc 17
ccs 0
cts 15
cp 0
rs 9.8666
cc 2
nc 2
nop 0
crap 6
1
<?php
2
3
namespace GeminiLabs\SiteReviews\Modules;
4
5
class Rating
6
{
7
    public const CONFIDENCE_LEVEL_Z_SCORES = [
8
        50 => 0.67449,
9
        70 => 1.04,
10
        75 => 1.15035,
11
        80 => 1.282,
12
        85 => 1.44,
13
        90 => 1.64485,
14
        92 => 1.75,
15
        95 => 1.95996,
16
        96 => 2.05,
17
        97 => 2.17009,
18
        98 => 2.326,
19
        99 => 2.57583,
20
        '99.5' => 2.81,
21
        '99.8' => 3.08,
22
        '99.9' => 3.29053,
23
    ];
24
    public const MAX_RATING = 5;
25
    public const MIN_RATING = 0;
26
27
    /**
28
     * @param int[] $ratingCounts
29
     */
30 6
    public function average(array $ratingCounts, ?int $roundBy = null): float
31
    {
32 6
        $average = 0;
33 6
        $total = $this->totalCount($ratingCounts);
34 6
        if ($total > 0) {
35 6
            $average = $this->totalSum($ratingCounts) / $total;
36
        }
37 6
        if (is_null($roundBy)) {
38 6
            $roundBy = glsr()->filterInt('rating/round-by', 1);
39
        }
40 6
        $roundedAverage = round($average, intval($roundBy));
41 6
        return glsr()->filterFloat('rating/average', $roundedAverage, $average, $ratingCounts);
42
    }
43
44 6
    public function emptyArray(): array
45
    {
46 6
        return array_fill_keys(range(0, static::max()), 0);
47
    }
48
49
    public function format(float $rating): string
50
    {
51
        $roundBy = $rating > 0 ? 1 : 0;
52
        $roundBy = glsr()->filterInt('rating/round-by', $roundBy, $rating);
53
        return (string) number_format_i18n($rating, $roundBy);
54
    }
55
56
    public function isValid(int $rating): bool
57
    {
58
        return array_key_exists($rating, $this->emptyArray());
59
    }
60
61
    public static function labels(): array
62
    {
63
        $labels = [
64
            __('Excellent', 'site-reviews'),
65
            __('Very good', 'site-reviews'),
66
            __('Average', 'site-reviews'),
67
            __('Poor', 'site-reviews'),
68
            __('Terrible', 'site-reviews'),
69
        ];
70
        $max = static::max();
71
        if (5 !== $max) {
72
            $labels = array_map(
73
                fn ($stars) => sprintf(_n('%d star', '%d stars', $stars, 'site-reviews'), $stars),
74
                range($max, 1)
75
            );
76
        }
77
        return array_combine(range($max, 1), $labels);
78
    }
79
80
    /**
81
     * Get the lower bound for up/down ratings
82
     * Method receives an up/down ratings array: [1, -1, -1, 1, 1, -1].
83
     *
84
     * @see http://www.evanmiller.org/how-not-to-sort-by-average-rating.html
85
     * @see https://news.ycombinator.com/item?id=10481507
86
     * @see https://dataorigami.net/blogs/napkin-folding/79030467-an-algorithm-to-sort-top-comments
87
     * @see http://julesjacobs.github.io/2015/08/17/bayesian-scoring-of-ratings.html
88
     */
89
    public function lowerBound(array $upDownCounts = [0, 0], int $confidencePercentage = 95): float
90
    {
91
        $numRatings = array_sum($upDownCounts);
92
        if ($numRatings < 1) {
93
            return 0;
94
        }
95
        $z = static::CONFIDENCE_LEVEL_Z_SCORES[$confidencePercentage];
96
        $phat = 1 * $upDownCounts[1] / $numRatings;
97
        return (float) ($phat + $z * $z / (2 * $numRatings) - $z * sqrt(($phat * (1 - $phat) + $z * $z / (4 * $numRatings)) / $numRatings)) / (1 + $z * $z / $numRatings);
98
    }
99
100 55
    public static function max(): int
101
    {
102 55
        return max(1, glsr()->filterInt('const/MAX_RATING', static::MAX_RATING));
103
    }
104
105 55
    public static function min(): int
106
    {
107 55
        return max(0, glsr()->filterInt('const/MIN_RATING', static::MIN_RATING));
108
    }
109
110
    /**
111
     * @param array $noopedPlural The result of _n_noop()
112
     */
113 24
    public function optionsArray(array $noopedPlural = [], int $minRating = 1): array
114
    {
115 24
        $options = [];
116 24
        if (empty($noopedPlural)) {
117 24
            $noopedPlural = _n_noop('%s Star', '%s Stars', 'site-reviews');
118
        }
119 24
        $min = max($minRating, static::min());
120 24
        foreach (range(static::max(), $min) as $rating) {
121 24
            $title = translate_nooped_plural($noopedPlural, $rating, 'site-reviews');
122 24
            if (!str_contains($title, '%s')) {
123
                $title = "%s {$title}"; // because Arr::unique() is used for array values when defaults are merged.
124
            }
125 24
            $options[$rating] = wp_sprintf($title, $rating);
126
        }
127 24
        return $options;
128
    }
129
130
    /**
131
     * @param int[] $ratingCounts
132
     */
133
    public function overallPercentage(array $ratingCounts): float
134
    {
135
        return round($this->average($ratingCounts) * 100 / static::max(), 2);
136
    }
137
138
    /**
139
     * @param int[] $ratingCounts
140
     */
141
    public function percentages(array $ratingCounts): array
142
    {
143
        if (empty($ratingCounts)) {
144
            $ratingCounts = $this->emptyArray();
145
        }
146
        $percentages = [];
147
        $total = array_sum($ratingCounts);
148
        foreach ($ratingCounts as $index => $count) {
149
            $percentage = empty($count) ? 0 : $count / $total * 100;
150
            $percentages[$index] = (float) $percentage;
151
        }
152
        return $this->roundedPercentages($percentages);
153
    }
154
155
    /**
156
     * @param int[] $ratingCounts
157
     */
158 6
    public function ranking(array $ratingCounts): float
159
    {
160 6
        return glsr()->filterFloat('rating/ranking',
161 6
            $this->rankingUsingImdb($ratingCounts),
162 6
            $ratingCounts,
163 6
            $this
164 6
        );
165
    }
166
167
    /**
168
     * Get the bayesian ranking for an array of reviews
169
     * This formula is the same one used by IMDB to rank their top 250 films.
170
     *
171
     * @see https://www.xkcd.com/937/
172
     * @see https://districtdatalabs.silvrback.com/computing-a-bayesian-estimate-of-star-rating-means
173
     * @see http://fulmicoton.com/posts/bayesian_rating/
174
     * @see https://stats.stackexchange.com/questions/93974/is-there-an-equivalent-to-lower-bound-of-wilson-score-confidence-interval-for-va
175
     */
176 6
    public function rankingUsingImdb(array $ratingCounts, int $confidencePercentage = 70): float
177
    {
178 6
        $avgRating = $this->average($ratingCounts);
179
        // Represents a prior (your prior opinion without data) for the average star rating. A higher prior also means a higher margin for error.
180
        // This could also be the average score of all items instead of a fixed value.
181 6
        $bayesMean = ($confidencePercentage / 100) * static::max(); // prior, 70% = 3.5
182
        // Represents the number of ratings expected to begin observing a pattern that would put confidence in the prior.
183 6
        $bayesMinimal = 10; // confidence
184 6
        $numOfReviews = $this->totalCount($ratingCounts);
185 6
        return $avgRating > 0
186 6
            ? (float) (($bayesMinimal * $bayesMean) + ($avgRating * $numOfReviews)) / ($bayesMinimal + $numOfReviews)
187 6
            : (float) 0;
188
    }
189
190
    /**
191
     * The quality of a 5 star rating depends not only on the average number of stars but also on
192
     * the number of reviews. This method calculates the bayesian ranking of a page by its number
193
     * of reviews and their rating.
194
     *
195
     * @see http://www.evanmiller.org/ranking-items-with-star-ratings.html
196
     * @see https://stackoverflow.com/questions/1411199/what-is-a-better-way-to-sort-by-a-5-star-rating/1411268
197
     * @see http://julesjacobs.github.io/2015/08/17/bayesian-scoring-of-ratings.html
198
     *
199
     * @param int[] $ratingCounts
200
     */
201
    public function rankingUsingZScores(array $ratingCounts, int $confidencePercentage = 90): float
202
    {
203
        $ratingCountsSum = (float) $this->totalCount($ratingCounts) + static::max();
204
        $weight = $this->weight($ratingCounts, $ratingCountsSum);
205
        $weightPow2 = $this->weight($ratingCounts, $ratingCountsSum, true);
206
        $zScore = static::CONFIDENCE_LEVEL_Z_SCORES[$confidencePercentage];
207
        return $weight - $zScore * sqrt(($weightPow2 - pow($weight, 2)) / ($ratingCountsSum + 1));
208
    }
209
210
    /**
211
     * @param int[] $ratingCounts
212
     */
213 6
    public function totalCount(array $ratingCounts): int
214
    {
215 6
        $values = array_filter($ratingCounts, 'is_numeric');
216 6
        $values = array_map('intval', $values);
217 6
        if (isset($values[0]) && glsr()->filterBool('rating/ignore-zero-stars', true)) {
218 6
            $values[0] = 0; // ignore 0-star ratings when calculating the average and ranking
219
        }
220 6
        return (int) array_sum($values);
221
    }
222
223
    /**
224
     * Returns array sorted by key DESC.
225
     *
226
     * @param float[] $percentages
227
     */
228
    protected function roundedPercentages(array $percentages, int $totalPercent = 100): array
229
    {
230
        array_walk($percentages, function (&$percent, $index) {
231
            $percent = [
232
                'index' => $index,
233
                'percent' => floor($percent),
234
                'remainder' => fmod($percent, 1),
235
            ];
236
        });
237
        $indexes = wp_list_pluck($percentages, 'index');
238
        $remainders = wp_list_pluck($percentages, 'remainder');
239
        array_multisort($remainders, \SORT_DESC, \SORT_STRING, $indexes, \SORT_DESC, $percentages);
240
        $i = 0;
241
        if (array_sum(wp_list_pluck($percentages, 'percent')) > 0) {
242
            while (array_sum(wp_list_pluck($percentages, 'percent')) < $totalPercent) {
243
                ++$percentages[$i]['percent'];
244
                ++$i;
245
            }
246
        }
247
        array_multisort($indexes, \SORT_DESC, $percentages);
248
        return array_combine($indexes, wp_list_pluck($percentages, 'percent'));
249
    }
250
251
    /**
252
     * @param int[] $ratingCounts
253
     */
254 6
    protected function totalSum(array $ratingCounts): int
255
    {
256 6
        return (int) array_reduce(
257 6
            array_keys($ratingCounts),
258 6
            fn ($carry, $i) => $carry + ($i * $ratingCounts[$i]),
259 6
            0
260 6
        );
261
    }
262
263
    /**
264
     * @param int[] $ratingCounts
265
     */
266
    protected function weight(array $ratingCounts, float $ratingCountsSum, bool $powerOf2 = false): float
267
    {
268
        return (float) array_reduce(array_keys($ratingCounts),
269
            function ($count, $rating) use ($ratingCounts, $ratingCountsSum, $powerOf2) {
270
                $ratingLevel = $powerOf2
271
                    ? pow($rating, 2)
272
                    : $rating;
273
                return $count + ($ratingLevel * ($ratingCounts[$rating] + 1)) / $ratingCountsSum;
274
            },
275
            0
276
        );
277
    }
278
}
279