-
Notifications
You must be signed in to change notification settings - Fork 2
Create new easyscience base class and model_base class. #159
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This pull request does not contain a valid label. Please add one of the following labels: ['[scope] bug', '[scope] enhancement', '[scope] documentation', '[scope] significant', '[scope] maintenance']
Co-authored-by: Christian Dam Vedel <158568093+damskii9992@users.noreply.github.com>
rozyczko
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No dynamic parameter injections!
I think this is a start of something awesome.
Next stop - the interface or rather, the calculator factory...
| This method is used by the serializer to determine which arguments are needed | ||
| by the constructor to deserialize the object. | ||
| """ | ||
| base_cls = getattr(self, '__old_class__', self.__class__) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
__old_class__ is a part of the old design, set up in addLoggedProp which I think you wanted to avoid?
How about
spec = getfullargspec(self.__class__.__init__)NewBase doesn't use dynamic class creation and the __old_class__ attribute is never set on NewBase instances...
| It provides serialization capabilities as well as unique naming and display naming. | ||
| """ | ||
|
|
||
| def __init__(self, unique_name: Optional[str] = None, display_name: Optional[str] = None): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think NewBase should only deal with unique_names. Why do you consider display_name to be a part of it? It seems too unimportant - can be set by classes which inherit NewBase...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is why we should probably have an ADR discussion. To discuss what goes into the ground base class (this would be NewBase) and what goes into the derived classes :)
But I'm not against moving it into ModelBase.
| else: | ||
| out_dict[key] = SerializerBase._convert_from_dict(value) | ||
| return out_dict | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This implementation smells. Let's refactor it so it is easier to test.
| @staticmethod | ||
| def _deserialize_dict(in_dict: Dict[str, Any]) -> None: | ||
| """ | ||
| Deserialize a dictionary using from_dict for EasyScience objects and SerializerBase otherwise. | ||
| :param in_dict: dictionary to deserialize | ||
| :return: deserialized dictionary | ||
| """ | ||
| out_dict = {} | ||
| for key, value in in_dict.items(): | ||
| if not key.startswith('@'): | ||
| if isinstance(value, dict) and "@module" in value and value["@module"].startswith("easy") and '@class' in value: # noqa: E501 | ||
| module_name = value['@module'] | ||
| class_name = value['@class'] | ||
| try: | ||
| module = __import__(module_name, globals(), locals(), [class_name], 0) | ||
| except ImportError as e: | ||
| raise ImportError(f'Could not import module {module_name}') from e | ||
| if hasattr(module, class_name): | ||
| cls_ = getattr(module, class_name) | ||
| if hasattr(cls_, 'from_dict'): | ||
| out_dict[key] = cls_.from_dict(value) | ||
| else: | ||
| out_dict[key] = SerializerBase._convert_from_dict(value) | ||
| else: | ||
| raise ValueError(f'Class {class_name} not found in module {module_name}.') | ||
| else: | ||
| out_dict[key] = SerializerBase._convert_from_dict(value) | ||
| return out_dict | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@staticmethod
def _deserialize_dict(in_dict: Dict[str, Any]) -> Dict[str, Any]:
"""
Deserialize a dictionary using from_dict for ES objects and SerializerBase otherwise.
This method processes constructor arguments, skipping metadata keys starting with '@'.
:param in_dict: dictionary to deserialize
:return: deserialized dictionary with constructor arguments
"""
d = {
key: SerializerBase._deserialize_value(value)
for key, value in in_dict.items()
if not key.startswith('@')
}
return d
@staticmethod
def _deserialize_value(value: Any) -> Any:
"""
Deserialize a single value, using specialized handling for ES objects.
:param value:
:return: deserialized value
"""
if not SerializerBase._is_serialized_easyscience_object(value):
return SerializerBase._convert_from_dict(value)
module_name = value['@module']
class_name = value['@class']
try:
cls = SerializerBase._import_class(module_name, class_name)
# Prefer from_dict() method for ES objects
if hasattr(cls, 'from_dict'):
return cls.from_dict(value)
else:
return SerializerBase._convert_from_dict(value)
except (ImportError, ValueError) as e:
# Fallback to generic deserialization if class-specific fails
return SerializerBase._convert_from_dict(value)
@staticmethod
def _is_serialized_easyscience_object(value: Any) -> bool:
"""
Check if a value represents a serialized ES object.
:param value:
:return: True if this is a serialized ES object
"""
return (
isinstance(value, dict)
and "@module" in value
and value["@module"].startswith("easy")
and '@class' in value
)
@staticmethod
def _import_class(module_name: str, class_name: str):
"""
Import a class from a module name and class name.
:param module_name: name of the module
:param class_name: name of the class
:return: the imported class
:raises ImportError: if module cannot be imported
:raises ValueError: if class is not found in module
"""
try:
module = __import__(module_name, globals(), locals(), [class_name], 0)
except ImportError as e:
raise ImportError(f'Could not import module {module_name}') from e
if not hasattr(module, class_name):
raise ValueError(f'Class {class_name} not found in module {module_name}.')
return getattr(module, class_name)There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed, this is much better. I took inspiration from the _convert_from_dict method, which is why it ended up not being properly factorised.
The _is_serialized_easyscience_object method might be taking it too far though, being a single logical check.
|
|
||
| class ModelBase(NewBase): | ||
| """ | ||
| This is the base class for all model classes in EasyScience. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ModelBase is where we should add display_name, I think?
| params = [] | ||
| for attr_name in dir(self): | ||
| attr = getattr(self, attr_name) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
return [param for param in self.get_fit_parameters() if param.fixed]There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I considered this approach too. It's much simpler, for the other method too. The reason I didn't choose it was for performance. This implementation only runs through the list of attributes once, your suggestion runs through the produced list again to re-filter it. The performance gain is probably so minimal it isn't worth it though :/
| def get_free_parameters(self) -> List[Parameter]: | ||
| """ | ||
| Get all parameters which are currently free to be fitted as a list. | ||
| :return: List of `Parameter` objects. | ||
| """ | ||
| params = [] | ||
| for attr_name in dir(self): | ||
| attr = getattr(self, attr_name) | ||
| if isinstance(attr, Parameter) and not attr.fixed and attr.independent: | ||
| params.append(attr) | ||
| return params |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
return [param for param in self.get_fit_parameters() if not param.fixed]|
|
||
| @classmethod | ||
| def from_dict(cls, obj_dict: Dict[str, Any]) -> None: | ||
| """ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
multiple conditionals are icky. I'd like to rewrite this to be more modular, but can't be arsed now.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I fixed it. Using the _is_serialized_easyscience_object method you refactored in the SerializerBase class. Clearly it was useful to refactor it even though it was only a simple logical check.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## develop #159 +/- ##
===========================================
+ Coverage 73.06% 80.82% +7.76%
===========================================
Files 47 50 +3
Lines 3943 4246 +303
Branches 671 739 +68
===========================================
+ Hits 2881 3432 +551
+ Misses 868 629 -239
+ Partials 194 185 -9
Flags with carried forward coverage won't be shown. Click here to find out more.
|
|
|
||
| @unique_name.setter | ||
| def unique_name(self, new_unique_name: str): | ||
| """Set a new unique name for the object. The old name is still kept in the map. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is the old name still kept in the map? Just curious; I haven't taken the time to even try to understand how the map works.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This was decided back in #16 under Andrews comment and Andreas reply. Thank god that we have ADR's and paper trails to follow now to remember these things ;)
| params += attr.get_all_parameters() | ||
| return params | ||
|
|
||
| def get_fit_parameters(self) -> List[Parameter]: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm pretty sure the fitter uses this method to choose which Parameters to fit, so we need to be a bit careful here
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That is exactly why I named this method like this. I wanted to name it get_fittable_parameters, but the Fitter expects this method name :/
I will obviously make unit tests and integration tests to check that this implementation works with the Fitter :)
…cience into new_base_class
… into new_base_class
No description provided.