Series and DataFrame are core classes and data structures in pandas, and of course they are Python classes too, so there are some minor distinction when involving attribute access between pandas DataFrame and normal Python objects. But it's well documented and can be easily understood. Just a few points to note:
- In Python, users may dynamically add data attributes of their own to an instance object using attribute access. - >>> class Dog(object):
...     pass
>>> dog = Dog()
>>> vars(dog)
{}
>>> superdog = Dog()
>>> vars(superdog)
{}
>>> dog.legs = 'I can run.'
>>> superdog.wings = 'I can fly.'
>>> vars(dog)
{'legs': 'I can run.'}
>>> vars(superdog)
{'wings': 'I can fly.'}
 
- In pandas, index and column are closely related to the data structure, you may access an index on a Series, column on a DataFrame as an attribute. - >>> import pandas as pd
>>> import numpy as np
>>> data = np.random.randint(low=0, high=10, size=(2,2))
>>> df = pd.DataFrame(data, columns=['a', 'b'])
>>> df
   a  b
0  7  6
1  5  8
>>> vars(df)
{'_is_copy': None, 
 '_data': BlockManager
    Items: Index(['a', 'b'], dtype='object')
    Axis 1: RangeIndex(start=0, stop=2, step=1)
    IntBlock: slice(0, 2, 1), 2 x 2, dtype: int64,
 '_item_cache': {}}
 
- But, pandas attribute access is mainly a convenience for reading from and modifying an existing element of a Series or column of a DataFrame. - >>> df.a
0    7
1    5
Name: a, dtype: int64
>>> df.b = [1, 1]
>>> df
   a  b
0  7  1
1  5  1
 
- And, the convenience is a tradeoff for full functionality. E.g. you can create a DataFrame object with column names - ['space bar', '1', 'loc', 'min', 'index'], but you can't access them as an attribute, because they are either not a valid Python identifier (- 1,- space bar) or conflict with an existing attribute name.
 - >>> data = np.random.randint(0, 10, size=(2, 5))
>>> df_special_col_names = pd.DataFrame(data, columns=['space bar', '1', 'loc', 'min', 'index'])
>>> df_special_col_names
   space bar  1  loc  min  index
0          4  4    4    8      9
1          3  0    1    2      3
 
- In these cases, the - .loc,- .ilocand- []indexing is the defined way to fullly access/operate index and columns of Series and DataFrame objects.
 - >>> df_special_col_names['space bar']
0    4
1    3
Name: space bar, dtype: int64
>>> df_special_col_names.loc[:, 'min']
0    8
1    2
Name: min, dtype: int64
>>> df_special_col_names.iloc[:, 1]
0    4
1    0
Name: 1, dtype: int64
 
- Another important difference is when trying to create a new column for DataFrame. As you can see, - df.c = df.a + df.bjust created an new attribute along side to the core data structure, so starting from version- 0.21.0and later, this behavior will raise a- UserWarning(silent no more).
 - >>> df
   a  b
0  7  1
1  5  1
 - >>> df.c = df.a + df.b
__main__:1: UserWarning: Pandas doesn't allow columns to be created via a new attribute name - see https://pandas.pydata.org/pandas-docs/stable/indexing.html#attribute-access`
 - >>> df['d'] = df.a + df.b
>>> df
   a  b  d
0  7  1  8
1  5  1  6
>>> df.c
0    8
1    6
dtype: int64
>>> vars(df)
{'_is_copy': None, 
 '_data': 
    BlockManager
    Items: Index(['a', 'b', 'd'], dtype='object')
    Axis 1: RangeIndex(start=0, stop=2, step=1)
    IntBlock: slice(0, 2, 1), 2 x 2, dtype: int64
    IntBlock: slice(2, 3, 1), 1 x 2, dtype: int64, 
 '_item_cache': {},
 'c': 0    8
      1    6
      dtype: int64}
 
- Finally, to create a new column for DataFrame, never use attribute access. The correct way is to use either - []or- .locindexing:
 - >>> df
   a  b
0  7  6
1  5  8
>>> df['c'] = df.a + df.b 
>>> # OR
>>> df.loc[:, 'c'] = df.a + df.b
>>> df # c is an new added column
   a  b   c
0  7  6  13
1  5  8  13