It's not about the types, it's about rebinding and name shadowing. When you assign directly to an attribute of an instance, you bind an attribute of that name on the instance. So animal1.y = 14 is saying "for this specific instance, it should have an attribute named y with the value 14". It doesn't matter that the class had an attribute of the same name, those are only found as a fallback when reading an attribute (there's some weirdness with the descriptor protocol I'm glossing over here, but that's the general rule).
By contrast, when you say animal1.x['num'] = 14 you are not writing to x. You are reading from animal1.x (which, since the instance lacks that attribute, it's read from the class), then writing into the thing you just read. The behavior is exactly the same as if you said:
xalias = animal1.x
xalias['num'] = 14
animal1.x is clearly not being written in that broken up version, and it's not being written to in the compact version either, you just modified the dict through an alias. You've told Python "please load from animal1.x, then assign 14 to the key 'num' within whatever you just loaded."
If you had instead said animal1.x = {'num': 14} ("for this specific instance, it should have an attribute named x with the value {'num': 14}"), it would behave just like it did for animal1.y = 14; the instance would get its own shadowing attribute named x that hides the x from the class, with it's own separate dictionary, because you bound a new object ("wrote") to the attribute on the instance.
This behavior shares certain similarities with the scoping rules for functions. If you have a function that never writes to a variable of a given name, only reads from it, it will read that variable from an outer (enclosing, global, or builtin) scope. If you assign to that variable though (without using global or nonlocal declarations to override the default behavior), it becomes a local, and its not possible to read the variable of that name from outer scopes without jumping through hoops. In the case of function scope, this choice is statically determined when the function is compiled (the variable is either local or nonlocal for the entire body of the function), the only difference with instance attributes shadowing class attributes is that it occurs dynamically (the instance attribute can be created lazily and only begins shadowing the class attribute when it's actually created).
It's also easier to work around the issue for instance/class attributes than for local/nonlocal variables; if you wanted the assignment animal1.y = 14 to replace the class attribute instead, even if you didn't know what class animal1 came from, you could do type(animal1).y = 14. That will show the change in animal2.y just fine, because you modified the original class (type(animal1) is returning Animal itself here), not an instance of it.
animal1.x['num'] = 14is updating. Butanimal1.y = 14is overriding since an integer is an immutable type. But still I expected to change take effect for both objects....intbeing an immutable type.intbeing immutable just means there is no way to modify it in-place, so rebinding is the only option.animal1.x = {'num': 14}would rebind in just the same way thatanimal1.y = 14is doing, even thoughdicts are mutable, what matters is whether you're rebinding the attribute (which causes auto-vivification of an instance attribute) or reading the object out of the attribute and then modifying it in-place.