Bonjour à tous.
Une fois de plus, c'est une question de design qui m'amène. J'utilise principalement le polymorphisme dans le but de proposer plusieurs algorithmes fondamentalement différents pour résoudre un même problème. Prenons ici un exemple réel : la construction d'un modèle sur base d'une collection de points.
Le principe est simple. Je dois pouvoir entrainer mon modèle sur une base de données. Une fois que c'est fait, je dois pouvoir l'évaluer sur de nouveaux points. On a donc une classe abstraite qui ressemble à ça.
1 2 3 4 5 6 7 8 | class Model { public: virtual ~Model() {} virtual Model* clone() const = 0; virtual void train(const TrainingSet& training_set) = 0; virtual void evaluate(TrainingSet::Element& element) = 0; }; |
Certains des algorithmes qui seront implémentés ont néanmoins besoin d'informations supplémentaires, comme des valeurs d'hyperparamètres. Je vois deux façons de faire, j'aimerais votre avis sur la question.
Première idée : chaque classe est responsable de collecter ses info avant l'appel à la fonction train. Exemple avec un algorithme quelconque nécessitant un critère de convergence via un epsilon.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | class ModelToto : public Model { public: ModelToto() : Model(), epsilon(1.0e-12) {} ModelToto(double eps) : Model(), epsilon(eps) {} ModelToto(const ModelToto& model) : Model(model), epsilon(model.epsilon) {} virtual ~ModelToto() {} virtual Model* clone() const { return new ModelToto(*this); } void setEpsilon(double eps) { this->epsilon = eps; } virtual void train(const TrainingSet& training_set) { /* algorithme utilisant this->epsilon */ } virtual void evaluate(TrainingSet::Element& elem) { /* evaluation n'utilisant pas this->epsilon */ } protected: double epsilon; } |
Cette façon de faire me semble bien propre, si ce n'est deux choses :
- Je dois stocker des informations dans la classe qui ne servent que durant l'exécution de la fonction train. Ici c'est juste un double, ce n'est pas énorme. Mais en pratique ce sont souvent des vecteurs d'hyperparamètes pré-fixés.
- Lorsque je manipule un Model*, je n'ai plus aucun contrôle sur les hyperparamètres. En particulier, certains des algorithmes ont une première phase d'optimisation de ces hyperparamètres. Une fois ceux-ci optimisés, l'entrainement est fait avec les meilleurs valeurs. Il m'est alors impossible de faire une copie de mon modèle via clone() et de lui dire "fais moi un train() avec les mêmes hyperparamètres sur cette nouvelle base de données" sans avoir moi-même connaissance de la classe finale.
J'ai donc envisagé une solution différente, mais qui contredit peut-être les principes du polymorphisme. J'ajoute à ma fonction train un pointeur vide par défaut que l'utilisateur peut utiliser pour donner de l'information supplémentaire à son algorithme.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | class TrainingInfo { public: virtual ~TrainingInfo() {} virtual TrainingInfo* clone() const = 0; }; class Model { public: virtual ~Model() {} virtual Model* clone() const = 0; void train(const TrainingSet& training_set, const TrainingInfo* info = 0) { this->trainingInfo = info; this->trainModel(); } const TrainingInfo* getInfo() const { return this->trainingInfo; } virtual void evaluate(TrainingSet::Element& element) = 0; protected: virtual void trainModel() = 0; protected: const TrainingInfo* trainingInfo; }; |
L'utilisateur est alors libre de définir une classe héritant de TrainingInfo et de l'utiliser dans son algorithme.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | class TrainingInfoToto : public TrainingInfo { public: TrainingInfoToto(double eps) : TrainingInfo(), epsilon(eps) {} TrainingInfoToto(const TrainingInfoToto& info) : TrainingInfo(info), epsilon(info.epsilon) {} virtual ~TrainingInfoToto() {} virtual TrainingInfo* clone() const { return new TrainingInfoToto(*this); } double getEpsilon() const { return this->epsilon; } protected: double epsilon; }; class ModelToto : public Model { public: ModelToto() : Model() {} ModelToto(const ModelToto& model) : Model(model) {} virtual ~ModelToto() {} virtual Model* clone() const { return new ModelToto(*this); } virtual void evaluate(TrainingSet::Element& elem) { /* ... */ } protected: virtual void trainModel() { TrainingInfoToto* toto_info = 0; if(this->trainingInfo) toto_info = dynamic_cast<const TrainingInfoToto*>(this->trainingInfo); /* algorithme utilisant toto_info->getEpsilon() s'il existe */ } } |
Cette configuration me permet de faire par exemple des choses comme ceci qui ne nécessite pas de connaître le type final de la classe (je ne vois pas comment faire autrement) :
1 2 3 4 5 6 7 8 9 10 | double evaluate_quality(const Model* model, const TrainingSet& training_set) { TrainingInfo* tmp_info = model->getInfo().clone(); tmp_info->lockHyperparameters(); Model* tmp_model = model->clone(); temp_model->train(training_set, info); //... delete tmp_model; delete tmp_info; } |
Qu'en pensez-vous ? Y a-t-il d'autres façons de faire ce genre de chose ?
De façon générale, est-ce une bonne idée d'ajouter un pointeur nul par défaut aux fonctions virtuelles qui pourraient potentiellement avoir besoin de plus d'arguments ?
Merci d'avance.