Your IP : 18.117.186.131


Current Path : /home/bitrix/ext_www/crm.klimatlend.ua/bitrix/modules/crm/lib/ml/
Upload File :
Current File : /home/bitrix/ext_www/crm.klimatlend.ua/bitrix/modules/crm/lib/ml/scoring.php

<?php

namespace Bitrix\Crm\Ml;

use Bitrix\Bitrix24\Feature;
use Bitrix\Crm\DealTable;
use Bitrix\Crm\Ml\Agent\ModelTrainer;
use Bitrix\Crm\Ml\Controller\Details;
use Bitrix\Crm\Ml\Internals\ModelTrainingTable;
use Bitrix\Crm\Ml\Internals\PredictionHistoryTable;
use Bitrix\Crm\Ml\Internals\PredictionQueueTable;
use Bitrix\Crm\Ml\Model;
use Bitrix\Crm\Settings\LeadSettings;
use Bitrix\Crm\Timeline\ScoringController;
use Bitrix\Main\Error;
use Bitrix\Main\Event;
use Bitrix\Main\Loader;
use Bitrix\Main\Localization\Loc;
use Bitrix\Main\ModuleManager;
use Bitrix\Main\Result;
use Bitrix\Main\Type\DateTime;
use Bitrix\Ml\Client;

class Scoring
{
	const PREDICTION_BATCH = "batch";
	const PREDICTION_REAL_TIME = "realtime";
	const PREDICTION_IMMEDIATE = "immediate";

	const EVENT_INITIAL_PREDICTION = "initial";
	const EVENT_ENTITY_UPDATE = "update";
	const EVENT_ACTIVITY = "activity";

	const MINIMAL_TRAINING_SET = 2000;
	const MINIMAL_CLASS_SIZE = 200;

	const RETRAIN_PERIOD = 30; // days

	const ERROR_MODEL_ALREADY_EXISTS = "model_already_exists";
	const ERROR_NOT_ENOUGH_DATA = "not_enough_data";
	const ERROR_TOO_SOON = "too_soon";

	public static function getMinimalTrainingSetSize()
	{
		return defined("CRM_ML_MINIMAL_TRAINING_SET_SIZE") ? CRM_ML_MINIMAL_TRAINING_SET_SIZE : static::MINIMAL_TRAINING_SET;
	}

	public static function getMinimalClassSize()
	{
		return defined("CRM_ML_MINIMAL_TRAINING_CLASS_SIZE") ? CRM_ML_MINIMAL_TRAINING_CLASS_SIZE : static::MINIMAL_CLASS_SIZE;
	}

	/**
	 * Starts training of the model, if all of the pre-requirements are met, such as:
	 *  - ml model should not exists, trainer will create it later
	 *  - last training should be in state finished
	 *
	 * @param Model\Base $model Scoring model to train.
	 * @return Result
	 */
	public static function startModelTraining(Model\Base $model)
	{
		$result = new Result();
		if(!Loader::includeModule("ml"))
		{
			return $result->addError(new Error("ML module is not installed"));
		}

		if(!static::isEnabled())
		{
			return $result->addError(new Error("Scoring is not enabled for your tariff"));
		}

		if($model->getMlModel())
		{
			return $result->addError(new Error("ML model should be deleted prior to starting learning process"));
		}

		$lastTraining = static::getLastTraining($model);
		if($lastTraining && !in_array($lastTraining["STATE"], [TrainingState::FINISHED, TrainingState::CANCELED]))
		{
			return $result->addError(new Error("Model " . $model->getName() . " is already in training"));
		}

		list($successfulRecords, $failedRecords) = $model->getTrainingSetSize();
		$totalRecords = $successfulRecords + $failedRecords;

		if($totalRecords < static::getMinimalTrainingSetSize()
			|| $successfulRecords < static::getMinimalClassSize()
			|| $failedRecords < static::getMinimalClassSize()
		)
		{
			return $result->addError(new Error("Not enough data to start model training"));
		}

		// check model training state

		$scheduleResult = ModelTrainer::scheduleTraining($model);
		if(!$scheduleResult->isSuccess())
		{
			return $result->addErrors($scheduleResult->getErrors());
		}

		return $result;
	}

	/**
	 * Checks if scoring model is suitable to start training.
	 *
	 * @param Model\Base $model Scoring model.
	 * @return Result
	 */
	public static function canStartTraining(Model\Base $model, $useCache = false)
	{
		$result = new Result();

		if($model->getState() !== false)
		{
			return $result->addError(new Error("Model already exists", static::ERROR_MODEL_ALREADY_EXISTS));
		}

		if($useCache)
		{
			list($successfulRecords, $failedRecords) = $model->getCachedTrainingSetSize();
		}
		else
		{
			list($successfulRecords, $failedRecords) = $model->getTrainingSetSize();
		}
		$totalRecords = $successfulRecords + $failedRecords;
		if($totalRecords < static::getMinimalTrainingSetSize()
			|| $successfulRecords < static::getMinimalClassSize()
			|| $failedRecords < static::getMinimalClassSize()
		)
		{
			return $result->addError(new Error("Not enough data to train model", static::ERROR_NOT_ENOUGH_DATA));
		}

		$lastTraining = static::getLastTraining($model);
		if($lastTraining)
		{
			if($lastTraining["DATE_FINISH"] instanceof DateTime)
			{
				$lastTrainingTimestamp = $lastTraining["DATE_FINISH"]->getTimestamp();
				$retrainPeriodInSeconds = static::RETRAIN_PERIOD * 24 * 60 * 60;
				if((time() - $lastTrainingTimestamp) < $retrainPeriodInSeconds)
				{
					return $result->addError(new Error("You can not start training. Too little time passed since the last training", static::ERROR_TOO_SOON));
				}
			}
		}

		return $result;
	}

	public static function deleteMlModel(Model\Base $model)
	{
		$result = new Result();
		if(!Loader::includeModule("ml"))
		{
			return $result->addError(new Error("ML module is not installed"));
		}

		$lastTraining = static::getLastTraining($model);
		if($lastTraining["STATE"] !== TrainingState::FINISHED)
		{
			ModelTrainer::cancelTraining($lastTraining["ID"]);
		}

		$mlModel = $model->getMlModel();
		if(!$mlModel)
		{
			return $result;
		}

		$deletionResult = $mlModel->deleteCascade();
		if(!$deletionResult->isSuccess())
		{
			return $result->addErrors($deletionResult->getErrors());
		}

		$model->unassociateMlModel();

		return $result;
	}

	/**
	 * Return id of the scheduled request.
	 *
	 * @param int $entityTypeId
	 * @param int $entityId
	 * @param string $type
	 * @param array $additionalParameters
	 *  - EVENT_TYPE
	 *  - ASSOCIATED_ACTIVITY_ID
	 *
	 * @return int
	 */
	public static function queuePredictionUpdate($entityTypeId, $entityId, array $additionalParameters = [])
	{
		$entityTypeId = (int)$entityTypeId;
		$entityId = (int)$entityId;
		
		if(!static::isMlAvailable() || !static::isEnabled() || !$entityTypeId || !$entityId)
		{
			return false;
		}

		$scoringModel = static::getScoringModel($entityTypeId, $entityId);
		if(!$scoringModel || !$scoringModel->isReady())
		{
			return false;
		}

		if(isset($additionalParameters['TYPE']))
		{
			$type = $additionalParameters['TYPE'];
			unset($additionalParameters['TYPE']);
		}
		else
		{
			$type = self::PREDICTION_REAL_TIME;
		}

		// 1. checking for another pending request
		$latestPrediction = PredictionQueueTable::getList([
			"select" => ["ID"],
			"filter" => [
				"=ENTITY_TYPE_ID" => $entityTypeId,
				"=ENTITY_ID" => $entityId
			],
			"limit" => 1,
		])->fetch();

		if($latestPrediction)
		{
			return $latestPrediction["ID"];
		}

		$scheduledRequest = new PredictionQueue();
		$scheduledRequest->setEntityTypeId($entityTypeId);
		$scheduledRequest->setEntityId($entityId);
		$scheduledRequest->setType($type);
		$scheduledRequest->setAdditionalParameters($additionalParameters);
		$insertResult = $scheduledRequest->save();

		if(!$insertResult->isSuccess())
		{
			return false;
		}

		$scheduledId = $insertResult->getId();
		if($type === self::PREDICTION_REAL_TIME && $scoringModel->isReady())
		{
			// Try to execute request immediately, if forking is available. Otherwise, request will be executed with agent.
			if(\CMain::forkActions([\Bitrix\Crm\Ml\PredictionQueue::class, "executeRequest"], [$scheduledId]))
			{
				$scheduledRequest->setState(PredictionQueue::STATE_EXECUTING);
			}
			else
			{
				// forking is not available
				$scheduledRequest->setState(PredictionQueue::STATE_IDLE);
			}
			$scheduledRequest->save();
		}
		else if($type === self::PREDICTION_IMMEDIATE && $scoringModel->isReady())
		{
			$scheduledRequest->setState(PredictionQueue::STATE_EXECUTING);
			$scheduledRequest->save();
			PredictionQueue::executeRequest($scheduledId);
		}
		else
		{
			$scheduledRequest->delay();
		}

		return $scheduledRequest->getId();
	}

	/**
	 * @param $entityTypeId
	 * @param $entityId
	 * @param array $parameters
	 * - EVENT_TYPE string
	 * - ASSOCIATED_ACTIVITY_ID int
	 * @return Result
	 */
	public static function updatePrediction($entityTypeId, $entityId, array $parameters = [])
	{
		$result = new Result();
		if(!Loader::includeModule("ml"))
		{
			return $result->addError(new Error("ML module is not installed"));
		}
		if(!static::isEnabled())
		{
			return $result->addError(new Error("Scoring is not enabled for your tariff"));
		}

		$scoringModel = static::getScoringModel($entityTypeId, $entityId);

		if(!$scoringModel || !$scoringModel->isReady())
		{
			$result->addError(new Error($scoringModel ? "Scoring model is not ready" : "Scoring model is not found"));
			return $result;
		}
		$featuresVector = $scoringModel->buildFeaturesVector($entityId);

		$mlClient = new Client();
		$predictionResult = $mlClient->predictRecord([
			"modelName" => $scoringModel->getName(),
			"fields" => $featuresVector
		]);

		if(!$predictionResult->isSuccess())
		{
			$result->addErrors($predictionResult->getErrors());
			return $result;
		}
		$answer = $predictionResult->getData();

		$predictionHistoryRecord = [
			"ENTITY_TYPE_ID" => $entityTypeId,
			"ENTITY_ID" => $entityId,
			"ANSWER" => $answer["label"],
			"SCORE" => round($answer["score"], 2),
			"MODEL_NAME" => $scoringModel->getName(),
			"EVENT_TYPE" => (string)$parameters["EVENT_TYPE"] ?: null,
			"ASSOCIATED_ACTIVITY_ID" => (int)$parameters["ASSOCIATED_ACTIVITY_ID"] ?: null,
		];

		$previousPrediction = PredictionHistoryTable::getRow([
			"filter" => [
				"=ENTITY_TYPE_ID" => $entityTypeId,
				"=ENTITY_ID" => $entityId
			],
			"order" => [
				"ID" => "DESC"
			]
		]);

		if($previousPrediction)
		{
			$delta = $predictionHistoryRecord["SCORE"] - $previousPrediction["SCORE"];
			$predictionHistoryRecord["SCORE_DELTA"] = round($delta, 2);

			// skip adding new prediction record, if score is not changed
			if($delta < 0.01)
			{
				return $result;
			}
		}

		$addResult = PredictionHistoryTable::add($predictionHistoryRecord);
		if(!$addResult->isSuccess())
		{
			$result->addErrors($addResult->getErrors());
			return $result;
		}
		$predictionId = $addResult->getId();
		$predictionHistoryRecord["ID"] = $predictionId;
		\Bitrix\Crm\Timeline\ScoringController::getInstance()->onCreate($predictionId, ["FIELDS" => $predictionHistoryRecord]);

		if($entityTypeId == \CCrmOwnerType::Deal)
		{
			$dealManager = new \CCrmDeal();
			$dealFields = [
				"PROBABILITY" => floor($predictionHistoryRecord['SCORE'] * 100)
			];
			$dealManager->Update($entityId, $dealFields, false, true, [
				"REGISTER_SONET_EVENT" => false,
				"ENABLE_SYSTEM_EVENTS" => false,
				"IS_SYSTEM_ACTION" => true
			]);
		}

		static::sendPredictionUpdatePullEvent($entityTypeId, $entityId, $predictionHistoryRecord);
		$result->setData($predictionHistoryRecord);
		return $result;
	}

	/**
	 * Deletes prediction with the given id.
	 *
	 * @param int $historyId
	 */
	public static function deletePrediction($historyId)
	{
		$historyRecord = PredictionHistoryTable::getRowById($historyId);

		if(!$historyRecord)
		{
			return false;
		}

		PredictionHistoryTable::delete($historyId);

		ScoringController::getInstance()->onDelete($historyId, [
			"ENTITY_TYPE_ID" => $historyRecord["ENTITY_TYPE_ID"],
			"ENTITY_ID" => $historyRecord["ENTITY_ID"],
		]);

		return true;
	}

	/**
	 * Removes references to this activity
	 *
	 * @param $activityId
	 */
	public static function onActivityDelete($activityId)
	{
		$cursor = PredictionHistoryTable::getList([
			"select" => ["ID"],
			"filter" => [
				"=ASSOCIATED_ACTIVITY_ID" => $activityId
			]
		]);

		while ($row = $cursor->fetch())
		{
			PredictionHistoryTable::update($row["ID"], [
				"ASSOCIATED_ACTIVITY_ID" => null
			]);
		}
	}

	/**
	 * Deletes prediction history records, associated with the entity.
	 *
	 * @param int $entityTypeId Entity type.
	 * @param int $entityId Entity id.
	 * @return void
	 */
	public static function onEntityDelete($entityTypeId, $entityId)
	{
		PredictionHistoryTable::deleteBatch([
			"=ENTITY_TYPE_ID" => $entityTypeId,
			"=ENTITY_ID" => $entityId
		]);
	}

	/**
	 * Replaces entity type and id in history records.
	 *
	 * @param int $entityTypeId Old entity type.
	 * @param int $entityId Old entity id.
	 * @param int @newEntityTypeId New entity type.
	 * @param int @newEntityId New entity id.
	 * @return void
	 */
	public static function replaceAssociatedEntity($entityTypeId, $entityId, $newEntityTypeId, $newEntityId)
	{
		PredictionHistoryTable::updateBatch(
			[
				"ENTITY_TYPE_ID" => $newEntityTypeId,
				"ENTITY_ID" => $newEntityId
			],
			[
				"=ENTITY_TYPE_ID" => $entityTypeId,
				"=ENTITY_ID" => $entityId
			]
		);
	}

	/**
	 * @param int $entityTypeId
	 * @param int $entityId
	 * @return Model\Base
	 */
	public static function getScoringModel($entityTypeId, $entityId)
	{
		switch ($entityTypeId)
		{
			case \CCrmOwnerType::Lead:
				return new Model\LeadScoring(Model\LeadScoring::MODEL_NAME);
			case \CCrmOwnerType::Deal:
				$modelName = Model\DealScoring::getModelNameByDeal($entityId);
				if(!$modelName)
				{
					return null;
				}
				return new Model\DealScoring($modelName);
			default:
				return null;
		}
	}

	public static function getAvailableModelNames()
	{
		$result = Model\DealScoring::getModelNames();
		if(LeadSettings::isEnabled())
		{
			$result = array_merge(Model\LeadScoring::getModelNames(), $result);
		}
		return $result;
	}

	/**
	 * @param $modelId
	 * @return Model\Base
	 */
	public static function getModelByName($modelName)
	{
		$possibleModels = [Model\LeadScoring::class, Model\DealScoring::class];

		foreach ($possibleModels as $model)
		{
			$possibleNames = $model::getModelNames();

			if(in_array($modelName, $possibleNames))
			{
				return new $model($modelName);
			}
		}

		return null;
	}

	/**
	 * Returns available classes to work with scoring models.
	 *
	 * @return array
	 */
	public static function getModelClasses()
	{
		$result = [];
		if (LeadSettings::isEnabled())
		{
			$result[] = Model\LeadScoring::class;
		}
		$result[] = Model\DealScoring::class;
		return $result;
	}

	/**
	 * Return current training fields for the specified model.
	 *
	 * @param Model\Base $model
	 * @return array|false
	 */
	public static function getLastTraining(Model\Base $model)
	{
		return ModelTrainingTable::getList([
			"filter" => [
				"=MODEL_NAME" => $model->getName()
			],
			"order" => [
				"ID" => "desc"
			],
			"limit" => 1
		])->fetch();
	}

	/**
	 * @param Event $event
	 */
	public static function onMlModelStateChange(Event $event)
	{
		if(!Loader::includeModule("ml"))
		{
			return;
		}

		$mlModel = $event->getParameter("model");
		$model = Scoring::getModelByName($mlModel->getName());
		Details::onModelUpdate($model);

		$currentTraining = Scoring::getLastTraining($model);
		if($currentTraining && !in_array($currentTraining["STATE"], [TrainingState::FINISHED, TrainingState::CANCELED]))
		{
			$updatedTrainingFields = [];
			// update latest training
			// update performance metric
			switch ($model->getState())
			{
				case \Bitrix\Ml\Model::STATE_TRAINING:
					$updatedTrainingFields["STATE"] = TrainingState::TRAINING;
					break;
				case \Bitrix\Ml\Model::STATE_EVALUATING:
					$updatedTrainingFields["STATE"] = TrainingState::EVALUATING;
					break;
				case \Bitrix\Ml\Model::STATE_READY:
					$updatedTrainingFields["STATE"] = TrainingState::FINISHED;
					$updatedTrainingFields["DATE_FINISH"] = new DateTime();
					$performance = $event->getParameter("performance");
					if($performance && $performance["AUC"])
					{
						$updatedTrainingFields["AREA_UNDER_CURVE"] = (float)$performance["AUC"];
					}
					break;
				default:
					break;
			}

			ModelTrainingTable::update($currentTraining["ID"], $updatedTrainingFields);

			$currentTraining = array_merge($currentTraining, $updatedTrainingFields);
			Details::onTrainingProgress($model, $currentTraining);
		}
	}

	/**
	 * Returns true if machine learning is installed for this instance.
	 *
	 * @return bool
	 */
	public static function isMlAvailable()
	{
		return ModuleManager::isModuleInstalled("ml");
	}

	/**
	 * Returns true if scoring is enabled for this portal by the tariffs.
	 */
	public static function isEnabled()
	{
		if(!Loader::includeModule("bitrix24"))
		{
			return true;
		}

		return Feature::isFeatureEnabled("crm_scoring");
	}

	/**
	 * Returns current prediction record or false if prediction is not found.
	 *
	 * @param int $entityTypeId Entity type id.
	 * @param int $entityId Id of the entity.
	 * @return array|false
	 */
	public static function getCurrentPrediction($entityTypeId, $entityId)
	{
		$model = static::getScoringModel($entityTypeId, $entityId);
		if(!$model)
		{
			return false;
		}

		return Internals\PredictionHistoryTable::getList([
			'select' => [
				'ANSWER',
				'SCORE',
				'SCORE_DELTA',
				'CREATED',
				'EVENT_TYPE',
				'ASSOCIATED_ACTIVITY_ID'
			],
			'filter' => [
				'=ENTITY_TYPE_ID' => $entityTypeId,
				'=ENTITY_ID' => $entityId,
				'=MODEL_NAME' => $model->getName(),
				'=IS_PENDING' => 'N'
			],
			'order' => [
				'CREATED' => 'desc'
			],
			'limit' => 1
		])->fetch();
	}

	/**
	 * Tries to create first prediction for the given entity. Preconditions:
 	 *  - ml module should be installed
	 *  - model for this entity type should be in ready state
	 *  - this entity should not have another predictions
	 *
	 * @param int $entityTypeId Type of the entity.
	 * @param int $entityId Id of the entity.
	 * @param bool $isImmediate Should prediction request be executed immediately.
	 *
	 * @return bool
	 */
	public static function tryCreateFirstPrediction($entityTypeId, $entityId, $isImmediate = false)
	{
		if(!static::isMlAvailable() || !Loader::includeModule("ml") || !static::isEnabled())
		{
			return false;
		}

		$model = static::getScoringModel($entityTypeId, $entityId);
		if(!$model || $model->getState() !== \Bitrix\Ml\Model::STATE_READY)
		{
			return false;
		}

		$predictionCheck = PredictionHistoryTable::getList([
			"select" => ["ID"],
			"filter" => [
				"=ENTITY_TYPE_ID" => $entityTypeId,
				"=ENTITY_ID" => $entityId
			]
		]);

		if($predictionCheck->fetch())
		{
			return false;
		}

		$queueCheck = PredictionQueueTable::getList([
			"select" => ["ID"],
			"filter" => [
				"=STATE" => PredictionQueue::STATE_IDLE,
				"=ENTITY_TYPE_ID" => $entityTypeId,
				"=ENTITY_ID" => $entityId
			]
		]);

		if($row = $queueCheck->fetch())
		{
			PredictionQueue::executeRequest($row["ID"]);
		}
		else
		{
			static::queuePredictionUpdate($entityTypeId, $entityId, [
				"TYPE" => $isImmediate ? static::PREDICTION_IMMEDIATE : static::PREDICTION_REAL_TIME,
				"EVENT_TYPE" => static::EVENT_INITIAL_PREDICTION
			]);
		}

		return true;
	}

	public static function sendPredictionUpdatePullEvent($entityTypeId, $entityId, $predictionRecord)
	{
		if(!Loader::includeModule("pull"))
		{
			return;
		}

		\CPullWatch::AddToStack(
			static::getPredictionUpdatePullTag($entityTypeId, $entityId),
			[
				"module_id" => "crm",
				"command" => "predictionUpdate",
				"params" => [
					"entityType" => \CCrmOwnerType::ResolveName($entityTypeId),
					"entityId" => $entityId,
					"predictionRecord" => $predictionRecord
				]
			]
		);
	}

	public static function getPredictionUpdatePullTag($entityTypeId, $entityId)
	{
		$entityType = \CCrmOwnerType::ResolveName($entityTypeId);
		return "CRM_ML_SCORING_PREDICTION_" . $entityType . "_" . $entityId;
	}

	public static function getLicenseInfoTitle()
	{
		return Loc::getMessage("CRM_SCORING_LICENSE_TITLE");
	}

	public static function getLicenseInfoText()
	{
		$result =
				"<p>".Loc::getMessage("CRM_SCORING_LICENSE_TEXT_P1")."</p>".
				"<p>".Loc::getMessage("CRM_SCORING_LICENSE_TEXT_P2")."</p>".
				"<p>".Loc::getMessage("CRM_SCORING_LICENSE_TEXT_P3")."</p>";

		return $result;
	}
}