Software engineer не должен строить модели с нуля, но должен понимать ключевые концепции для:
PHP Go C# Python
<?php
declare(strict_types=1);
namespace App\ML;
final readonly class ClassificationMetrics
{
/**
* Calculate precision, recall, F1 from confusion matrix.
*
* @param int $tp True Positives
* @param int $fp False Positives
* @param int $fn False Negatives
* @param int $tn True Negatives
* @return array{precision: float, recall: float, f1: float, accuracy: float}
*/
public function calculate(int $tp, int $fp, int $fn, int $tn): array
{
$precision = ($tp + $fp) > 0 ? $tp / ($tp + $fp) : 0.0;
$recall = ($tp + $fn) > 0 ? $tp / ($tp + $fn) : 0.0;
$f1 = ($precision + $recall) > 0
? 2 * ($precision * $recall) / ($precision + $recall)
: 0.0;
$accuracy = ($tp + $fp + $fn + $tn) > 0
? ($tp + $tn) / ($tp + $fp + $fn + $tn)
: 0.0;
return [
'precision' => round($precision, 4),
'recall' => round($recall, 4),
'f1' => round($f1, 4),
'accuracy' => round($accuracy, 4),
];
}
/**
* Calculate Precision@K for ranking/recommendation.
*
* @param array<string> $predicted Predicted items (ranked)
* @param array<string> $relevant Ground truth relevant items
* @param int $k Top-K to consider
*/
public function precisionAtK(array $predicted, array $relevant, int $k): float
{
$topK = array_slice($predicted, 0, $k);
$relevantInTopK = count(array_intersect($topK, $relevant));
return $k > 0 ? $relevantInTopK / $k : 0.0;
}
}
package ml
import "math"
// ClassificationMetrics calculates classification quality metrics.
type ClassificationMetrics struct{}
// MetricsResult holds precision, recall, F1 and accuracy scores.
type MetricsResult struct {
Precision float64
Recall float64
F1 float64
Accuracy float64
}
// Calculate computes precision, recall, F1 from confusion matrix.
func (ClassificationMetrics) Calculate(tp, fp, fn, tn int) MetricsResult {
precision := 0.0
if tp+fp > 0 {
precision = float64(tp) / float64(tp+fp)
}
recall := 0.0
if tp+fn > 0 {
recall = float64(tp) / float64(tp+fn)
}
f1 := 0.0
if precision+recall > 0 {
f1 = 2 * (precision * recall) / (precision + recall)
}
accuracy := 0.0
total := tp + fp + fn + tn
if total > 0 {
accuracy = float64(tp+tn) / float64(total)
}
return MetricsResult{
Precision: math.Round(precision*10000) / 10000,
Recall: math.Round(recall*10000) / 10000,
F1: math.Round(f1*10000) / 10000,
Accuracy: math.Round(accuracy*10000) / 10000,
}
}
// PrecisionAtK calculates Precision@K for ranking/recommendation.
func (ClassificationMetrics) PrecisionAtK(predicted, relevant []string, k int) float64 {
if k <= 0 {
return 0.0
}
topK := predicted
if len(topK) > k {
topK = topK[:k]
}
relevantSet := make(map[string]struct{}, len(relevant))
for _, r := range relevant {
relevantSet[r] = struct{}{}
}
count := 0
for _, item := range topK {
if _, ok := relevantSet[item]; ok {
count++
}
}
return float64(count) / float64(k)
}
namespace App.Ml;
/// Holds precision, recall, F1 and accuracy scores.
public readonly record struct MetricsResult(
double Precision,
double Recall,
double F1,
double Accuracy);
public sealed class ClassificationMetrics
{
// Calculate computes precision, recall, F1 from confusion matrix.
public MetricsResult Calculate(int tp, int fp, int fn, int tn)
{
var precision = tp + fp > 0 ? (double)tp / (tp + fp) : 0.0;
var recall = tp + fn > 0 ? (double)tp / (tp + fn) : 0.0;
var f1 = precision + recall > 0
? 2 * (precision * recall) / (precision + recall)
: 0.0;
var total = tp + fp + fn + tn;
var accuracy = total > 0 ? (double)(tp + tn) / total : 0.0;
return new MetricsResult(
Math.Round(precision, 4),
Math.Round(recall, 4),
Math.Round(f1, 4),
Math.Round(accuracy, 4));
}
// PrecisionAtK calculates Precision@K for ranking/recommendation.
public double PrecisionAtK(IReadOnlyList<string> predicted, IReadOnlyList<string> relevant, int k)
{
if (k <= 0)
{
return 0.0;
}
var relevantSet = relevant.ToHashSet();
var hits = predicted.Take(k).Count(relevantSet.Contains);
return (double)hits / k;
}
}
from dataclasses import dataclass
from typing import Sequence
@dataclass(frozen=True, slots=True)
class MetricsResult:
"""Holds precision, recall, F1 and accuracy scores."""
precision: float
recall: float
f1: float
accuracy: float
class ClassificationMetrics:
def calculate(self, tp: int, fp: int, fn: int, tn: int) -> MetricsResult:
"""Compute precision, recall, F1 from confusion matrix."""
precision = tp / (tp + fp) if tp + fp > 0 else 0.0
recall = tp / (tp + fn) if tp + fn > 0 else 0.0
f1 = (
2 * (precision * recall) / (precision + recall)
if precision + recall > 0
else 0.0
)
total = tp + fp + fn + tn
accuracy = (tp + tn) / total if total > 0 else 0.0
return MetricsResult(
precision=round(precision, 4),
recall=round(recall, 4),
f1=round(f1, 4),
accuracy=round(accuracy, 4),
)
def precision_at_k(
self,
predicted: Sequence[str],
relevant: Sequence[str],
k: int,
) -> float:
"""Calculate Precision@K for ranking/recommendation."""
if k <= 0:
return 0.0
relevant_set = set(relevant)
hits = sum(1 for item in predicted[:k] if item in relevant_set)
return hits / k
## Overfitting и Underfitting
Feature engineering — процесс создания признаков (features) из сырых данных для улучшения качества модели.
PHP Go C# Python
<?php
declare(strict_types=1);
namespace App\ML;
/**
* Transform raw user data into ML features.
*/
final readonly class UserFeatureExtractor
{
/**
* Extract features for user churn prediction.
*
* @param array<array{amount: float, created_at: string}> $orders
* @param array<array{page: string, timestamp: string}> $pageViews
* @return array<string, float>
*/
public function extractFeatures(
array $userProfile,
array $orders,
array $pageViews,
): array {
$now = new \DateTimeImmutable();
$registeredAt = new \DateTimeImmutable($userProfile['registered_at']);
return [
// User profile features
'account_age_days' => (float) $registeredAt->diff($now)->days,
'has_avatar' => $userProfile['avatar'] !== null ? 1.0 : 0.0,
'profile_completeness' => $this->calculateCompleteness($userProfile),
// Order features
'total_orders' => (float) count($orders),
'total_revenue' => array_sum(array_column($orders, 'amount')),
'avg_order_value' => count($orders) > 0
? array_sum(array_column($orders, 'amount')) / count($orders)
: 0.0,
'days_since_last_order' => $this->daysSinceLastOrder($orders, $now),
'order_frequency' => $this->orderFrequency($orders, $registeredAt, $now),
// Engagement features
'page_views_last_7d' => (float) $this->countRecentViews($pageViews, 7),
'page_views_last_30d' => (float) $this->countRecentViews($pageViews, 30),
'unique_pages_last_30d' => (float) $this->uniquePagesViewed($pageViews, 30),
// Trend features
'orders_trend' => $this->calculateTrend($orders),
];
}
private function calculateCompleteness(array $profile): float
{
$fields = ['name', 'email', 'phone', 'avatar', 'address'];
$filled = 0;
foreach ($fields as $field) {
if (!empty($profile[$field])) {
$filled++;
}
}
return $filled / count($fields);
}
private function daysSinceLastOrder(array $orders, \DateTimeImmutable $now): float
{
if (empty($orders)) {
return 999.0; // No orders — high value indicates inactivity
}
$lastOrder = max(array_column($orders, 'created_at'));
$lastDate = new \DateTimeImmutable($lastOrder);
return (float) $lastDate->diff($now)->days;
}
private function orderFrequency(
array $orders,
\DateTimeImmutable $registeredAt,
\DateTimeImmutable $now,
): float {
$totalDays = max(1, $registeredAt->diff($now)->days);
return count($orders) / ($totalDays / 30); // Orders per month
}
private function countRecentViews(array $pageViews, int $days): int
{
$cutoff = (new \DateTimeImmutable())->modify("-{$days} days");
$count = 0;
foreach ($pageViews as $view) {
if (new \DateTimeImmutable($view['timestamp']) > $cutoff) {
$count++;
}
}
return $count;
}
private function uniquePagesViewed(array $pageViews, int $days): int
{
$cutoff = (new \DateTimeImmutable())->modify("-{$days} days");
$pages = [];
foreach ($pageViews as $view) {
if (new \DateTimeImmutable($view['timestamp']) > $cutoff) {
$pages[$view['page']] = true;
}
}
return count($pages);
}
private function calculateTrend(array $orders): float
{
// Compare last 30 days vs previous 30 days
$now = new \DateTimeImmutable();
$recent = 0;
$previous = 0;
foreach ($orders as $order) {
$date = new \DateTimeImmutable($order['created_at']);
$daysAgo = $date->diff($now)->days;
if ($daysAgo <= 30) {
$recent++;
} elseif ($daysAgo <= 60) {
$previous++;
}
}
if ($previous === 0) {
return $recent > 0 ? 1.0 : 0.0;
}
return ($recent - $previous) / $previous; // -1 to +inf
}
}
package ml
import (
"math"
"time"
)
// UserProfile represents raw user data.
type UserProfile struct {
RegisteredAt string
Name string
Email string
Phone string
Avatar string
Address string
}
// Order represents a user order.
type Order struct {
Amount float64
CreatedAt string
}
// PageView represents a page visit event.
type PageView struct {
Page string
Timestamp string
}
// Features holds extracted ML features as a map.
type Features map[string]float64
// UserFeatureExtractor transforms raw user data into ML features.
type UserFeatureExtractor struct{}
// ExtractFeatures extracts features for user churn prediction.
func (e UserFeatureExtractor) ExtractFeatures(profile UserProfile, orders []Order, pageViews []PageView) Features {
now := time.Now()
registeredAt, _ := time.Parse(time.RFC3339, profile.RegisteredAt)
accountAgeDays := now.Sub(registeredAt).Hours() / 24
hasAvatar := 0.0
if profile.Avatar != "" {
hasAvatar = 1.0
}
totalRevenue := 0.0
for _, o := range orders {
totalRevenue += o.Amount
}
avgOrderValue := 0.0
if len(orders) > 0 {
avgOrderValue = totalRevenue / float64(len(orders))
}
return Features{
"account_age_days": accountAgeDays,
"has_avatar": hasAvatar,
"profile_completeness": e.calculateCompleteness(profile),
"total_orders": float64(len(orders)),
"total_revenue": totalRevenue,
"avg_order_value": avgOrderValue,
"days_since_last_order": e.daysSinceLastOrder(orders, now),
"order_frequency": e.orderFrequency(orders, registeredAt, now),
"page_views_last_7d": float64(e.countRecentViews(pageViews, 7)),
"page_views_last_30d": float64(e.countRecentViews(pageViews, 30)),
"unique_pages_last_30d": float64(e.uniquePagesViewed(pageViews, 30)),
"orders_trend": e.calculateTrend(orders, now),
}
}
func (UserFeatureExtractor) calculateCompleteness(p UserProfile) float64 {
fields := []string{p.Name, p.Email, p.Phone, p.Avatar, p.Address}
filled := 0
for _, f := range fields {
if f != "" {
filled++
}
}
return float64(filled) / float64(len(fields))
}
func (UserFeatureExtractor) daysSinceLastOrder(orders []Order, now time.Time) float64 {
if len(orders) == 0 {
return 999.0
}
var latest time.Time
for _, o := range orders {
t, _ := time.Parse(time.RFC3339, o.CreatedAt)
if t.After(latest) {
latest = t
}
}
return math.Floor(now.Sub(latest).Hours() / 24)
}
func (UserFeatureExtractor) orderFrequency(orders []Order, registeredAt, now time.Time) float64 {
totalDays := math.Max(1, now.Sub(registeredAt).Hours()/24)
return float64(len(orders)) / (totalDays / 30)
}
func (UserFeatureExtractor) countRecentViews(views []PageView, days int) int {
cutoff := time.Now().AddDate(0, 0, -days)
count := 0
for _, v := range views {
t, _ := time.Parse(time.RFC3339, v.Timestamp)
if t.After(cutoff) {
count++
}
}
return count
}
func (UserFeatureExtractor) uniquePagesViewed(views []PageView, days int) int {
cutoff := time.Now().AddDate(0, 0, -days)
pages := make(map[string]struct{})
for _, v := range views {
t, _ := time.Parse(time.RFC3339, v.Timestamp)
if t.After(cutoff) {
pages[v.Page] = struct{}{}
}
}
return len(pages)
}
func (UserFeatureExtractor) calculateTrend(orders []Order, now time.Time) float64 {
recent, previous := 0, 0
for _, o := range orders {
t, _ := time.Parse(time.RFC3339, o.CreatedAt)
daysAgo := int(now.Sub(t).Hours() / 24)
if daysAgo <= 30 {
recent++
} else if daysAgo <= 60 {
previous++
}
}
if previous == 0 {
if recent > 0 {
return 1.0
}
return 0.0
}
return float64(recent-previous) / float64(previous)
}
namespace App.Ml;
public sealed record UserProfile(
DateTimeOffset RegisteredAt,
string Name,
string Email,
string Phone,
string? Avatar,
string Address);
public sealed record Order(decimal Amount, DateTimeOffset CreatedAt);
public sealed record PageView(string Page, DateTimeOffset Timestamp);
/// Transforms raw user data into ML features.
public sealed class UserFeatureExtractor
{
private readonly TimeProvider _clock;
public UserFeatureExtractor(TimeProvider clock) => _clock = clock;
// Extract features for user churn prediction.
public IReadOnlyDictionary<string, double> ExtractFeatures(
UserProfile profile,
IReadOnlyList<Order> orders,
IReadOnlyList<PageView> pageViews)
{
var now = _clock.GetUtcNow();
var totalRevenue = orders.Sum(o => (double)o.Amount);
return new Dictionary<string, double>
{
// User profile features
["account_age_days"] = (now - profile.RegisteredAt).TotalDays,
["has_avatar"] = string.IsNullOrEmpty(profile.Avatar) ? 0.0 : 1.0,
["profile_completeness"] = CalculateCompleteness(profile),
// Order features
["total_orders"] = orders.Count,
["total_revenue"] = totalRevenue,
["avg_order_value"] = orders.Count > 0 ? totalRevenue / orders.Count : 0.0,
["days_since_last_order"] = DaysSinceLastOrder(orders, now),
["order_frequency"] = OrderFrequency(orders, profile.RegisteredAt, now),
// Engagement features
["page_views_last_7d"] = CountRecentViews(pageViews, 7, now),
["page_views_last_30d"] = CountRecentViews(pageViews, 30, now),
["unique_pages_last_30d"] = UniquePagesViewed(pageViews, 30, now),
// Trend features
["orders_trend"] = CalculateTrend(orders, now),
};
}
private static double CalculateCompleteness(UserProfile profile)
{
string?[] fields = [profile.Name, profile.Email, profile.Phone, profile.Avatar, profile.Address];
return (double)fields.Count(f => !string.IsNullOrWhiteSpace(f)) / fields.Length;
}
private static double DaysSinceLastOrder(IReadOnlyList<Order> orders, DateTimeOffset now)
{
if (orders.Count == 0)
{
return 999.0; // No orders — high value indicates inactivity
}
var last = orders.Max(o => o.CreatedAt);
return Math.Floor((now - last).TotalDays);
}
private static double OrderFrequency(
IReadOnlyList<Order> orders,
DateTimeOffset registeredAt,
DateTimeOffset now)
{
var totalDays = Math.Max(1, (now - registeredAt).TotalDays);
return orders.Count / (totalDays / 30); // Orders per month
}
private static double CountRecentViews(IReadOnlyList<PageView> views, int days, DateTimeOffset now)
{
var cutoff = now.AddDays(-days);
return views.Count(v => v.Timestamp > cutoff);
}
private static double UniquePagesViewed(IReadOnlyList<PageView> views, int days, DateTimeOffset now)
{
var cutoff = now.AddDays(-days);
return views.Where(v => v.Timestamp > cutoff).Select(v => v.Page).Distinct().Count();
}
private static double CalculateTrend(IReadOnlyList<Order> orders, DateTimeOffset now)
{
// Compare last 30 days vs previous 30 days
var recent = 0;
var previous = 0;
foreach (var order in orders)
{
var daysAgo = (now - order.CreatedAt).TotalDays;
if (daysAgo <= 30)
{
recent++;
}
else if (daysAgo <= 60)
{
previous++;
}
}
if (previous == 0)
{
return recent > 0 ? 1.0 : 0.0;
}
return (double)(recent - previous) / previous; // -1 to +inf
}
}
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from typing import Sequence
@dataclass(frozen=True, slots=True)
class UserProfile:
registered_at: datetime
name: str
email: str
phone: str
avatar: str | None
address: str
@dataclass(frozen=True, slots=True)
class Order:
amount: Decimal
created_at: datetime
@dataclass(frozen=True, slots=True)
class PageView:
page: str
timestamp: datetime
class UserFeatureExtractor:
"""Transform raw user data into ML features."""
def extract_features(
self,
profile: UserProfile,
orders: Sequence[Order],
page_views: Sequence[PageView],
) -> dict[str, float]:
now = datetime.now(timezone.utc)
total_revenue = float(sum(o.amount for o in orders))
return {
# User profile features
"account_age_days": (now - profile.registered_at).days,
"has_avatar": 1.0 if profile.avatar else 0.0,
"profile_completeness": self._completeness(profile),
# Order features
"total_orders": float(len(orders)),
"total_revenue": total_revenue,
"avg_order_value": total_revenue / len(orders) if orders else 0.0,
"days_since_last_order": self._days_since_last_order(orders, now),
"order_frequency": self._order_frequency(orders, profile.registered_at, now),
# Engagement features
"page_views_last_7d": float(self._count_recent_views(page_views, 7, now)),
"page_views_last_30d": float(self._count_recent_views(page_views, 30, now)),
"unique_pages_last_30d": float(self._unique_pages(page_views, 30, now)),
# Trend features
"orders_trend": self._trend(orders, now),
}
@staticmethod
def _completeness(profile: UserProfile) -> float:
fields = (profile.name, profile.email, profile.phone, profile.avatar, profile.address)
return sum(1 for f in fields if f) / len(fields)
@staticmethod
def _days_since_last_order(orders: Sequence[Order], now: datetime) -> float:
if not orders:
return 999.0 # No orders — high value indicates inactivity
last = max(o.created_at for o in orders)
return float((now - last).days)
@staticmethod
def _order_frequency(
orders: Sequence[Order],
registered_at: datetime,
now: datetime,
) -> float:
total_days = max(1, (now - registered_at).days)
return len(orders) / (total_days / 30) # Orders per month
@staticmethod
def _count_recent_views(views: Sequence[PageView], days: int, now: datetime) -> int:
cutoff = now - timedelta(days=days)
return sum(1 for v in views if v.timestamp > cutoff)
@staticmethod
def _unique_pages(views: Sequence[PageView], days: int, now: datetime) -> int:
cutoff = now - timedelta(days=days)
return len({v.page for v in views if v.timestamp > cutoff})
@staticmethod
def _trend(orders: Sequence[Order], now: datetime) -> float:
# Compare last 30 days vs previous 30 days
recent = 0
previous = 0
for order in orders:
days_ago = (now - order.created_at).days
if days_ago <= 30:
recent += 1
elif days_ago <= 60:
previous += 1
if previous == 0:
return 1.0 if recent else 0.0
return (recent - previous) / previous # -1 to +inf
## Data Split
Model drift — деградация качества модели со временем из-за изменения данных.
PHP Go C# Python
<?php
declare(strict_types=1);
namespace App\ML;
final readonly class DriftMonitor
{
/**
* Check if prediction distribution has changed significantly.
*
* @param array<float> $baseline Baseline prediction scores
* @param array<float> $current Current prediction scores
* @param float $threshold Alert threshold
*/
public function detectDrift(array $baseline, array $current, float $threshold = 0.1): DriftResult
{
$baselineMean = array_sum($baseline) / count($baseline);
$currentMean = array_sum($current) / count($current);
$drift = abs($currentMean - $baselineMean) / max(0.001, $baselineMean);
return new DriftResult(
driftDetected: $drift > $threshold,
driftScore: round($drift, 4),
baselineMean: round($baselineMean, 4),
currentMean: round($currentMean, 4),
);
}
}
final readonly class DriftResult
{
public function __construct(
public bool $driftDetected,
public float $driftScore,
public float $baselineMean,
public float $currentMean,
) {}
}
package ml
import "math"
// DriftResult holds the outcome of a drift detection check.
type DriftResult struct {
DriftDetected bool
DriftScore float64
BaselineMean float64
CurrentMean float64
}
// DriftMonitor checks if prediction distribution has changed.
type DriftMonitor struct{}
// DetectDrift compares baseline and current prediction distributions.
func (DriftMonitor) DetectDrift(baseline, current []float64, threshold float64) DriftResult {
if threshold == 0 {
threshold = 0.1
}
baselineMean := mean(baseline)
currentMean := mean(current)
drift := math.Abs(currentMean-baselineMean) / math.Max(0.001, baselineMean)
return DriftResult{
DriftDetected: drift > threshold,
DriftScore: math.Round(drift*10000) / 10000,
BaselineMean: math.Round(baselineMean*10000) / 10000,
CurrentMean: math.Round(currentMean*10000) / 10000,
}
}
func mean(values []float64) float64 {
if len(values) == 0 {
return 0
}
sum := 0.0
for _, v := range values {
sum += v
}
return sum / float64(len(values))
}
namespace App.Ml;
/// Holds the outcome of a drift detection check.
public readonly record struct DriftResult(
bool DriftDetected,
double DriftScore,
double BaselineMean,
double CurrentMean);
public sealed class DriftMonitor
{
// Check if prediction distribution has changed significantly.
public DriftResult DetectDrift(
IReadOnlyCollection<double> baseline,
IReadOnlyCollection<double> current,
double threshold = 0.1)
{
var baselineMean = Mean(baseline);
var currentMean = Mean(current);
var drift = Math.Abs(currentMean - baselineMean) / Math.Max(0.001, baselineMean);
return new DriftResult(
DriftDetected: drift > threshold,
DriftScore: Math.Round(drift, 4),
BaselineMean: Math.Round(baselineMean, 4),
CurrentMean: Math.Round(currentMean, 4));
}
private static double Mean(IReadOnlyCollection<double> values)
=> values.Count == 0 ? 0.0 : values.Sum() / values.Count;
}
from dataclasses import dataclass
from typing import Sequence
@dataclass(frozen=True, slots=True)
class DriftResult:
"""Holds the outcome of a drift detection check."""
drift_detected: bool
drift_score: float
baseline_mean: float
current_mean: float
class DriftMonitor:
def detect_drift(
self,
baseline: Sequence[float],
current: Sequence[float],
threshold: float = 0.1,
) -> DriftResult:
"""Check if prediction distribution has changed significantly."""
baseline_mean = self._mean(baseline)
current_mean = self._mean(current)
drift = abs(current_mean - baseline_mean) / max(0.001, baseline_mean)
return DriftResult(
drift_detected=drift > threshold,
drift_score=round(drift, 4),
baseline_mean=round(baseline_mean, 4),
current_mean=round(current_mean, 4),
)
@staticmethod
def _mean(values: Sequence[float]) -> float:
return sum(values) / len(values) if values else 0.0
## Итоги